import pickle
import threading
from urllib.error import HTTPError
from urllib.request import urlopen
import json
from filelock import FileLock
import hashlib
import time
import os
from flask import request, make_response, Response
import datetime
from wsgiref.handlers import format_date_time

app = None  # Placeholder for the Flask app instance

def set_flask_app(flask_app):
    global app
    app = flask_app

# Implementation of method decorator on a flask response function
def cache_response_to_disk(seconds_to_cache: int = None, file_name: str = None):
    """
    A decorator to cache the response of a function to disk for a specified duration.
    Args:
        seconds_to_cache (int, optional): The number of seconds to cache the response. 
            If not provided, defaults to the value of `CE_DASHBOARD_SERVER_CACHE_MINUTES` 
            from the application configuration multiplied by 60.
        file_name (str, optional): The name of the cache file. If not provided, a unique 
            cache file name is generated based on the query string.
    Returns:
        function: A wrapper function that caches the response of the decorated function 
        to a file on disk.
    Notes:
        - The cache directory is determined by the `CE_DASHBOARD_CACHE_DIRECTORY` 
          configuration in the application.
        - If the cache file does not exist or is expired, the decorated function is 
          executed, and its response is cached.
        - If the cache file exists and is valid, the cached response is returned.
        - A file lock is used to ensure thread-safe access to the cache file.
        - If an error occurs while writing to the cache file (e.g., disk full), the 
          cache file is removed.
    Returns:
        tuple: A tuple containing:
            - response (str): The response from the decorated function or the cached response.
            - cached_response (bool): A flag indicating whether the response was retrieved 
              from the cache (True) or generated by the function (False).
    """
    def decorator(func):
        def wrapper(*args, **kwargs):
            CACHE_DIR = app.config['CE_DASHBOARD_CACHE_DIRECTORY']
            effective_seconds_to_cache = seconds_to_cache
            if effective_seconds_to_cache is None:
                effective_seconds_to_cache = app.config['CE_DASHBOARD_SERVER_CACHE_MINUTES'] * 60
            if file_name is not None:
                cache_file = os.path.join(CACHE_DIR, file_name)
            else:
                # Create a unique cache file name based on the query string
                query_string = request.query_string.decode('utf-8')
                query_hash = hashlib.md5(query_string.encode()).hexdigest()
                cache_file = os.path.join(CACHE_DIR, query_hash)

            cached_response = True

            # Create cache directory if it does not exist
            os.makedirs(os.path.dirname(cache_file), exist_ok=True)

            # if filename ends with '.bin', use binary mode
            open_mode = 'a+'
            if cache_file.endswith('.bin'):
                open_mode = 'ab+'

            lock = FileLock(cache_file + ".lock",is_singleton=False)
            with lock:
                with open(cache_file, open_mode) as f:
                    fsize = os.path.getsize(cache_file)
                    fmtime = os.path.getmtime(cache_file)
                    if (fsize == 0) or \
                       (fmtime < (time.time() - effective_seconds_to_cache)):
                        cached_response = False
                        response = func(*args, **kwargs)
                        try:
                            f.seek(0)
                            f.truncate()
                            f.write(response)
                        except Exception as e:
                            # Remove cache file if any errors writing it (i.e. disk full)
                            os.remove(cache_file)
                            print(f"ERROR writing cache file {cache_file}: {e}")
                    else:
                        f.seek(0)
                        response = f.read()
            return response, cached_response
        return wrapper
    return decorator


def make_data_response(response_body: str, cached_response: bool, browser_cache_minutes: int = None) -> Response:
    """
    Creates an HTTP response for with specified caching headers and metadata.

    Args:
        response_body (str): The body of the HTTP response.
        cached_response (bool): Indicates whether the response is from the server cache.
        browser_cache_minutes (int, optional): The number of minutes the browser should cache the response.

    Returns:
        Response: A Flask Response object with the specified headers and content.

    Notes:
        - The 'Content-Type' header is set to "text/plain; charset=UTF-8".
        - The 'Expires' header specifies when the cached response should expire.
        - The 'Cache-Control' header defines the maximum age for browser caching.
        - A custom header 'X-HTCondorView-Cached-Response' indicates if the response is cached.
    """
    if (browser_cache_minutes is None):
        browser_cache_minutes = app.config['CE_DASHBOARD_BROWSER_CACHE_MINUTES']
    response = make_response(response_body)
    response.headers['Content-Type'] = "text/plain; charset=UTF-8"
    # Tell the browser to cache this data for a specified number of minutes
    browser_cache_seconds = browser_cache_minutes * 60
    now = datetime.datetime.now()
    expires = now + datetime.timedelta(minutes=browser_cache_minutes)
    expires_str = format_date_time(time.mktime(expires.timetuple()))
    response.headers['Expires'] = f"{expires_str}"
    response.headers['Cache-Control'] =  f"max-age={browser_cache_seconds}, public"
    # In a custom HTTP header, state if the response is from the cache or not
    response.headers['X-HTCondorView-Cached-Response'] = f"{cached_response}"
    return response


institution_db_lock = threading.Lock()
institution_db = {}

@cache_response_to_disk(file_name="institution_db.bin", seconds_to_cache=60*60)
def _getInstitutionDBPickle() -> bytes:
    tries = 0
    max_tries = 2
    INSTITUTION_DATABASE_URL = "https://topology-institutions.osg-htc.org/api/institution_ids"
    while tries < max_tries:
        try:
            with urlopen(INSTITUTION_DATABASE_URL) as f:
                for institution in json.load(f):
                    institution_id = institution.get("id")
                    if not institution_id:
                        continue
                    institution_id_short = institution_id.split("/")[-1]
                    institution["id_short"] = institution_id_short
                    institution_db[institution_id] = institution
                    institution_db[institution_id_short] = institution

                    # OSG_INSTITUTION_IDS mistakenly had the ROR IDs before ~2024-11-07,
                    # so we map those too (as long as they don't conflict with OSG IDs)
                    ror_id_short = (institution.get("ror_id") or "").split("/")[-1]
                    if ror_id_short and ror_id_short not in institution_db:
                        institution_db[ror_id_short] = institution
                print("INFO: Loaded institution database with %d entries" % len(institution_db))
        except HTTPError:
            time.sleep(2**tries)
            tries += 1
            if tries == max_tries and len(institution_db) == 0:
                raise
        else:
            break

    # Pickle institution_db into a binary string
    binary_data = pickle.dumps(institution_db)
    return binary_data

def getOrganizationFromInstitutionID(institution_id: str, default: str) -> str:
    """
    Returns the organization name from the institution ID.

    Args:
        institution_id (str): The institution ID.

    Returns:
        str: The organization name.
    """

    if institution_id == "Unknown":
        return default

    with institution_db_lock:
        global institution_db
        if institution_id in institution_db:
            return institution_db[institution_id].get("name", default)
        else:
            # If the institution ID is not found, reload the database
            # and try again. This is a fallback mechanism to ensure that the
            # institution database is up to date.
            institution_db = {}
            buffer, _ = _getInstitutionDBPickle()
            institution_db.update(pickle.loads(buffer))
            if institution_id in institution_db:
                return institution_db[institution_id].get("name", default)
            else:
                # If still not found, return the default value
                # and log a warning message.
                print(f"WARNING: Institution ID {institution_id} not found in database.")   

    return default
