import datetime
import glob
import http.client
import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlencode

import requests
from dateutil.tz import tzutc

import __main__
import skolo


class _Verbosity:
    """Module-level toggle without `global` (satisfies PLW0603)."""

    enabled = False


_REQUEST_TIMEOUT_SEC = 3


def set_verbose(value):
    _Verbosity.enabled = bool(value)


def __setServer__(local):
    credDir = os.path.expanduser('~') + os.sep + '.skolo' + os.sep
    if local:
        credFile = credDir + 'skolo.local.credentials'
        server = 'http://localhost:5000'
    else:
        credFile = credDir + 'skolo.credentials'
        try:
            config_path = os.path.expanduser('~') + os.sep + '.skolo' + os.sep + 'config'
            with open(config_path) as cfg:
                server = cfg.readlines()[0].split('server=')[-1]
        except Exception:
            server = 'https://skolocfd.com'
    return server, credFile


def __respOK__(response):
    return 'HTTPStatusCode' in response and response['HTTPStatusCode'] == 200 and not response['errors']


class conn:
    def __init__(self, useLocal=None):
        self.__reqSession__ = requests.Session()

        self.__server__, credFile = __setServer__(useLocal)

        if not os.path.exists(credFile):
            self._exc(401, 'Missing credentials. For information on generating valid credentials visit the link provided.', link=self.__server__ + '/docs?topic=Api#Configuring%20Credentials')
            self.__cred__ = 'None'
        else:
            with open(credFile) as cred_fp:
                self.__cred__ = cred_fp.readlines()[0]
        self._checkVersion()


    def _clearLine(self, text):
        if hasattr(self, 'verbose') and self.verbose:
            if hasattr(self, 'lastPrintLine') and self.lastPrintLine == text:
                return
            print(text)
            self.lastPrintLine = text
            return
        sys.stdout.write('\033[K') #clear line
        print(text.replace('\n',''), end='\r', flush=True)


    def _checkVersion(self, force=False):
        # Check for package updates
        verFile = os.path.expanduser('~') + os.sep + '.skolo' + os.sep + 'skolo.versionCheck'

        # mtime is UTC epoch seconds; compare in UTC so elapsed time is unambiguous (DTZ).
        stale = False
        if os.path.exists(verFile):
            last_check = datetime.datetime.fromtimestamp(
                os.path.getmtime(verFile), tz=datetime.UTC,
            ) + datetime.timedelta(days=7)
            stale = last_check < datetime.datetime.now(datetime.UTC)
        if not os.path.exists(verFile) or force or stale:
            try:
                vMax = max(
                    requests.get(
                        'https://pypi.org/pypi/skolo/json',
                        timeout=_REQUEST_TIMEOUT_SEC,
                    ).json()['releases'].keys(),
                )
            except Exception:
                vMax = skolo.__version__
            if skolo.__version__ != vMax:
                print('\nALERT: skolo-' + skolo.__version__ + ' is installed, but skolo-' + vMax + ' is available:\n    python -m pip install --upgrade skolo\n')
            with open(verFile, 'w'):
                pass


    def _genResp(self, HTTPStatusCode=200, errors=None, warnings=None, messages=None, link=''):
        if errors is None:
            errors = []
        if warnings is None:
            warnings = []
        if messages is None:
            messages = []
        return {
            'HTTPStatusCode': HTTPStatusCode,
            'errors': errors,
            'warnings': warnings,
            'messages': messages,
            'link': link,
        }


    def _exc(self, code, errMsg, link='' ):
        if isinstance(errMsg, str):
            errMsg = [errMsg]

        errResp = self._genResp(code, errors=errMsg, link=link)
        if hasattr(__main__, '__file__') and ('cliparser' in __main__.__file__.lower() or os.path.basename(__main__.__file__) == 'skolo'):
            print(json.dumps(errResp, indent=4, sort_keys=True))
            sys.exit(-1)
        else:
            print('    ERROR ' + str(code) + ': ' + ' '.join(errMsg))
            print('      ' + link)
            return errResp


    def _buildResp(self, r):
        resp = {}
        if r.status_code == 401:
            resp = self._exc(r.status_code, 'Invalid credentials. For information on generating valid credentials visit the link provided.', link=self.__server__ + '/docs?topic=Api#Configuring%20Credentials')
        elif r.status_code == 403:
            resp = self._exc(r.status_code, 'Expired credentials. Please generate a new authentication token at the link provided.', link=self.__server__ + '/docs?topic=Api#Configuring%20Credentials')
        elif r.status_code == 404:
            resp = self._exc(r.status_code, r.text)
        elif r.status_code == 429:
            try:
                rate = f" ({json.loads(r.text)['rateLimit']})" if json.loads(r.text)['rateLimit'] else ''
            except json.JSONDecodeError:
                rate = ''
            resp = self._exc(r.status_code, f'We limit the frequency of API calls{rate} to protect our servers against misuse/overload. Please reduce the frequency of this function, or contact us to request an increase.')
        elif r.status_code > 399:
            if r.text.strip():
                msg = r.text.strip()
            else:
                phrase = http.client.responses.get(r.status_code, 'Error')
                msg = f'Unhandled server error ({r.status_code} {phrase}) - Please contact us.'
            resp = self._exc(r.status_code, msg)

        # Detect HTML content
        content_type = r.headers.get('Content-Type', '')
        is_html = 'text/html' in content_type.lower()
        text = r.text.strip()

        if is_html or text.startswith(('<!DOCTYPE', '<html')):
            resp.update({'messages': ['Error: Server returned unexpected HTML content']})
        elif '[' not in r.text and '{' not in r.text:
            resp.update({'messages':[r.text]})
        else:
            try:
                resp = json.loads(r.text)
            except json.JSONDecodeError:
                resp.update({'messages': ['ERROR: Invalid JSON response from server']})

        resp.update({'HTTPStatusCode':r.status_code})
        return resp, r


    def _http(self, url, req, json=None, data=None, files=None):
        if _Verbosity.enabled:
            print('URL', url)
        args = {'url': url, 'json': json, 'data': data, 'files': files}
        args = {k: v for k, v in args.items() if v is not None}

        # Always set Authorization header for Bearer tokens
        headers = {'Authorization': f'Bearer {self.__cred__}'}
        if 'files' not in args and req != 'get':
            headers['Content-Type'] = 'application/json'

        args['headers'] = headers
        if req == 'get' and 'json' in args:
            args = {k: v for k, v in args.items() if k != 'json'}

        args['timeout'] = _REQUEST_TIMEOUT_SEC

        try:
            return self._buildResp(getattr(self.__reqSession__, req)(**args))
        except requests.exceptions.ConnectionError:
            try:
                self.__reqSession__.get('http://www.google.com', timeout=_REQUEST_TIMEOUT_SEC)
            except requests.exceptions.ConnectionError:
                resp = self._exc(500, 'Connection refused. It appears your internet connection is down.')
            else:
                resp = self._exc(500, 'Connection refused. It appears the Skolo server is unreachable.')
            return resp, None


    def _get(self, url, json=None):
        if json is None:
            json = {}
        return self._http(url, 'get', json)


    def _post(self, url, json=None):
        if json is None:
            json = {}
        return self._http(url, 'post', json)


    def _uploadFile(self, url, file, data):
        fd = {'file': ''}
        with open(file, 'rb') as fd['file']:
            return self._http(url, 'post', None, data, fd)


    def _buildUrl(self, pre, extra=None, filters=True):
        """Build URL: server + path + single query string from filters and optional extra params."""
        params = dict(self.__filters__) if filters else {}
        if extra:
            params.update(extra)
        if not params:
            return self.__server__ + pre
        return self.__server__ + pre + '?' + urlencode(params)


    def _filterUrl(self, filters, ignore=None):
        if ignore is None:
            ignore = []
        self.__filters__ = filters
        filtered = {k: v for k, v in filters.items() if k not in ignore}
        if not filtered:
            return ''
        return '?' + urlencode(filtered)


    def checkExist(self):
        """
        Checks if the Skolo instance (Project, Run, Orientation) exists on the server

        Returns
        -------
        response : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
            If the HTTPStatusCode in the dict is 200, the Project/Run/Orientaton exists
        """
        url = self._buildUrl('/api/exists')
        return self._get(url)[0]


    def _listChildren(self):
        url = self._buildUrl('/api/list')
        return self._get(url)[0]


    def _taskWatch(self, resp, cmd, queue, mins):
        watchUrl = self._buildUrl('/api/getStatus/' + resp['jobID'] + '/' + queue, extra={'fromApi': 'true'}, filters=False)
        t, dt, tMax, status = 0, 1, 60*mins, 'queueing'
        while t < tMax and status not in ['finished', 'failed']:
            watch, _ = self._get(watchUrl)
            status = watch['task']['status']

            if 'log' in watch['task']['meta'] and len(watch['task']['meta']['log']) > 0:
                lines = [line for line in watch['task']['meta']['log'] if not line.startswith(' ')]
                if lines != []:
                    self._clearLine(lines[-1])
            elif status != 'queueing':
                self._clearLine(cmd + ' job is ' + status)

            if 'geomCheckRhs' in watch['task']['meta']:
                watch['geomCheckRhs'] = watch['task']['meta']['geomCheckRhs']

            time.sleep(dt)
            t += dt
        print()
        watch['taskLog'] = watch['task']['meta']['log']
        watch['link'] = self._buildUrl('/case', extra={'step': 'upload'})
        del(watch['task'])

        return watch



# Generic account connection
class acct(conn):
    def __init__(self, args):
        kD = {'project':'proj', 'run':'case', 'orien':'rh'}
        self.__filters__={}
        for key in ['project', 'proj', 'run', 'case', 'orien', 'rh']:
            target = kD.get(key, key)
            if hasattr(args, key) and getattr(args, key):
                self.__filters__[target] = getattr(args, key)
        super().__init__(args.local)
        self.checkExist()


class Project(conn):
    def __init__(self, proj, useLocal=False):
        """
        Initialize a connection to a Skolo Project

        Inputs
        ----------
        proj : string
            Skolo project name
        useLocal : bool
            connect to local rather than remote server

        Returns
        -------
        run : object
            An instance of this class
        """

        self.proj = proj
        self.__filters__ = {'proj':proj}
        super().__init__(useLocal)
        self.checkExist()


    def listRuns(self):
        """
        Returns a list of all runs in the project
        """
        resp = self._listChildren()

        return resp.get('runs', [])



class Run(conn):
    def __init__(self, proj, run, useLocal=False):
        """
        Initialize a connection to a Skolo Run

        Inputs
        ----------
        proj : string
            Skolo project name
        run : string
            Run identifier: a run number (e.g. run754) or ``'latest'`` to use
            the last run in the project (resolved by the server).
        useLocal : bool
            connect to local rather than remote server

        Returns
        -------
        run : object
            An instance of this class
        """

        self.proj = proj
        self.run = run
        self.__filters__ = {'proj':proj, 'case':run}
        super().__init__(useLocal)
        self.checkExist()


    def listOrientations(self):
        """
        Returns a list of all orientations for a given run; no inputs.
        """
        resp = self._listChildren()
        return resp.get('orienList', [])


    def createNew(self, comment, topic='empty', copyGeometry=True, copyOrientations=True, copySettings=True, copyPostPro=True):
        """
        Function that creates a new run, based on the current run.

        Inputs
        ----------
        comment : string
            descriptive comment for the run
        topic : string
            descriptine topic for the run - equivalent to "topic" in the Skolo GUI
        copyGeometry : bool
            copy all geometry from the baseline run
        copyOrientations : bool
            copy all orientations from the baseline run
        copySettings : bool
            copy all run settings from the baseline run
        copyPostPro : bool
            copy all postPro templates from the baseline run

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        jsonData = {'topic':str(topic), 'comment':comment, 'geom':copyGeometry, 'settings':copySettings, 'copyRhs':copyOrientations, 'post':copyPostPro, 'cadID':''}
        url = self._buildUrl('/api/newCase')
        resp, _ = self._post(url, jsonData)
        return self._taskWatch(resp, 'newCase', 'berdQ', 3)



# Connection to specific proj:run:orientation
class Orientation(conn):
    def __init__(self, proj, run, orien, useLocal=False):
        """
        Initialize a connection to a Skolo Orientation within a Run

        Inputs
        ----------
        proj : string
            Skolo project name
        run : string
            Skolo run number
        orien : string
            Skolo orientation
        useLocal : bool
            connect to local rather than remote server

        Returns
        -------
        run : object
            An instance of this class
        """
        self.proj = proj
        self.run = run
        self.orien = orien
        self.useLocal = useLocal
        self.__filters__ = {'proj':proj, 'case':run, 'rh':orien}
        super().__init__(useLocal)
        self.checkExist()


    def geomCompare(self, force=False):
        """
        Submits a geometry comparison job vs. the baseline

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        cmd = 'geomCompare'
        url = self._buildUrl('/api/compareGeom', extra={'fromApi': 'true', 'fullCheck': 'false', 'debug': 'false'})
        jsonData = {}
        resp, _ = self._post(url, jsonData)

        # Catch case where server chooses not to run geomPrep
        if 'jobID' not in resp and 'rhError' in resp:
            resp['errors'] = [resp['rhError']]
            del resp['rhError']
            resp['HTTPStatusCode'] = 400
            return resp
        print(cmd.capitalize() + ' job is queueing', end='\r')

        # Watch job status
        return self._taskWatch(resp, cmd, 'geomQ', 5)


    def kinematics(self, force=False):
        """
        Submits kinematic model movement, and checks the resulting output geometry

        Inputs
        ----------
        force : bool
            forces re-running of kinematics for all rideheights whether needed or not - WARNING! overwrites files!

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        resp = self._geomTask('kinematics', force=force)

        # geomCheck all the output rhs
        for rh in resp.get('geomCheckRhs', []):
            print('RIDEHEIGHT', rh)
            orien = Orientation(self.proj, self.run, rh, self.useLocal)
            orien.submit('setup')

        return resp


    def geomPrep(self, force=False):
        """
        Submits geomPrep, and checks the resulting output geometry

        Inputs
        ----------
        force : bool
            forces re-running of all geomPrep on all CAD files - WARNING! overwrites files!

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        resp = self._geomTask('geomPrep', force=force)

        # geomCheck the output files
        if len(resp.get('errors', []) + resp.get('rhErrors', [])) == 0:
            self.submit('setup')

        return resp


    def _geomTask(self, cmd, force=False):
        """
        Submits geomPrep/kinematics

        Optional arguments:
            force:      forces re-running of all geomPrep on all CAD files - WARNING! overwrites files!
        """
        if self.orien != 'geomConstruction':
            return self._genResp(errors=['GeomPrep and Kinematics may only be run from the construion geometry orientation (geomConstruction)'])

        # Submit job
        extra = {'fromApi': 'true', 'rhSel': 'geomConstruction', 'fcnToRun': cmd}
        if force:
            extra['forceReRun'] = 'true'
        url = self._buildUrl('/api/runGeomTask', extra=extra)

        jsonData = {'files':[]}
        resp, _ = self._post(url, jsonData)

        if _Verbosity.enabled:
            print(f'{cmd.upper()} RESP', resp)

        # Catch case where server chooses not to run geomPrep
        if 'jobID' not in resp and 'rhError' in resp:
            print('ERRORS FOUND:', resp['rhError'])
            resp['errors'] = [resp['rhError']]
            del resp['rhError']
            resp['HTTPStatusCode'] = 400
            return resp
        print(cmd.capitalize() + ' job is queueing', end='\r')

        # Watch job status
        return self._taskWatch(resp, cmd, 'ansaQ', 20)


    def submit(self, cmd, costLimit=None):
        """
        Submits the current orientation with the desired command

        Inputs
        ----------
        cmd : string
            the command to run, which can be: setup, mesh, post, or submit (submit includes mesh, solve, post)
        costLimit : integer
            Specify a limit to acceptable estimated cost, above which job submission will be aborted

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        if self.orien == 'geomCommon':
            return self._genResp(errors=['geomCommon is not a valid orientation. Such an orientation may only used by the upload command'])

        if cmd is None:
            cmd = 'submit'

        # Submit job
        extra = {'fromApi': 'true'}
        if costLimit:
            float(costLimit)
            extra['costLimit'] = str(int(costLimit))
        url = self._buildUrl('/api/runBerd', extra=extra)
        jsonData = {'cmd':cmd, 'rhSel':self.__filters__['rh']}
        resp, _ = self._post(url, jsonData)
        print(cmd.capitalize() + ' job is queueing', end='\r')

        # Watch job status
        return self._taskWatch(resp, cmd, 'berdQ', 3)


    def _describeFile(self, f):
        return {'upload':True, 'size':os.path.getsize(f), 'mtime':str(datetime.datetime.fromtimestamp(os.path.getmtime(f), tz=tzutc()))}


    def upload(self, folder, force=False):
        """
        Uploads geometry to the orientation

        Inputs
        ----------
        folder : str or list of str
            The file(s), folder(s), or glob pattern(s) to upload. When the CLI
            receives shell-expanded args (e.g. ``-f path/foo*`` → multiple paths),
            this will be a list; otherwise a single string.
            file - the file will always be uploaded
            folder - step, STL or CATIA files in the folder which are NOT already up-to-date on the server will be uploaded (speeds things up)
            glob - e.g. ``*.stl`` or ``geom/*.stl.gz``; matched files (with allowed extensions) are uploaded
        force : bool
            force the upload of all files in the folder, even if they are already up-to-date on the server

        Returns
        -------
        resp : dict
            Skolo HTTP response dictionary, see https://www.skolocfd.com/docs?topic=Api
        """

        t0 = time.time()
        exts = ('.stl', '.stl.gz', '.vtp', '.vtp.gz', '.stp', '.step', 'catpart', '3dxml', '.log')

        # Normalize to list of paths (CLI may pass multiple when shell expands a glob)
        if folder is None or (isinstance(folder, (list, tuple)) and len(folder) == 0):
            self._exc('Specify -f/--folder with a path, folder, or glob pattern.')
        paths = [folder] if isinstance(folder, str) else list(folder)

        # Create list of files from all paths
        # When multiple paths (e.g. shell-expanded 90-120/*), treat as first-level only: add files, skip directories.
        files, tSize, t0 = {}, 0, time.time()
        shell_expanded = len(paths) > 1
        for path in paths:
            if os.path.isdir(path):
                if shell_expanded:
                    continue  # glob-style: only first-level files, do not add directory contents
                for item in sorted(os.listdir(path), key=str.casefold):
                    if item.endswith(exts):
                        ful = path + os.sep + item
                        files[ful] = self._describeFile(ful)
            elif os.path.isfile(path):
                if path.endswith(exts):
                    files[path] = self._describeFile(path)
                force = True
            elif '*' in path or '?' in path or '[' in path:
                # Wildcard pattern: expand and filter by allowed extensions
                for ful in sorted(glob.glob(path), key=str.casefold):
                    if os.path.isfile(ful) and ful.endswith(exts):
                        files[ful] = self._describeFile(ful)
            else:
                self._exc(415, 'Not a file or folder: ' + path)

        if not files:
            self._exc(415, 'No suitable files found with one of the extensions below. Check path(s), folder(s), or glob pattern(s): ' + ', '.join(exts))

        if _Verbosity.enabled:
            print(f' Files up consider: ({len(files)})')
            print('    ' + '\n    '.join(files))

        # See which files are actually out-of-date on server
        if not force:
            url = self._buildUrl('/api/needsUpload')
            respNeed, _ = self._post(url, json=files)
            for f, value in files.items():
                if f in respNeed['fileInfo'] and not respNeed['fileInfo'][f]['upload']:
                    value['upload'] = False
        else:
            respNeed = {'fileInfo': {f: {'upload': True} for f in files}}

        # Parallel uploading
        resp = {'HTTPStatusCode':200}
        url = self._buildUrl('/api/upload', extra={'units': 'Meters'})
        def parUpload(geom):
            resp, _ = self._uploadFile(url, geom, files[geom])
            self._clearLine('    Uploading: ' + geom)
            return (geom, resp)

        pool = ThreadPoolExecutor(max_workers=6)
        filesToUpload = sorted([item for item in files if force or respNeed['fileInfo'][item]['upload']], key=str.casefold)
        for geom in pool.map(parUpload, filesToUpload):
            if geom[1]['HTTPStatusCode'] == 200:
                files[geom[0]] = 'Upload successful'
                tSize += os.path.getsize(geom[0])
        self._clearLine('')

        if len(files) == 0:
            dest = os.path.abspath(paths[0]) if len(paths) == 1 else 'multiple paths'
            warnings = ['Zero suitable files (' + ', '.join(exts) + ') found at destination: ' + dest]
        else:
            warnings = []

        msg = f'Uploaded {tSize / (1024 * 1024):.1f} Mb from {len(filesToUpload)} files in {time.time() - t0:.1f} sec.'
        resp = {'HTTPStatusCode': resp['HTTPStatusCode'], 'messages': [msg], 'errors': [], 'warnings': warnings, 'fileInfo': files}
        resp['link'] = self._buildUrl('/case', extra={'step': 'upload'})
        return resp
