# -*- coding: utf-8 -*-
"""Carregador p\xfablico. A interface e a exporta\xe7\xe3o residem no m\xf3dulo .pyd."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import struct
import sys
import tempfile
import threading
import time
import urllib.parse
import urllib.request

PORTAL_API_URL = 'https://streetworksapp.com'
SETTINGS = Path.home() / '.plataforma_trafego_aimsun_exporter.json'
MAX_BINARY_BYTES = 32 * 1024 * 1024
IO_CONTEXT = threading.local()


class DownloadCancelled(RuntimeError):
    pass


def report(label, current=0, total=0):
    cancel = getattr(IO_CONTEXT, 'cancel', None)
    if cancel is not None and cancel.is_set():
        raise DownloadCancelled('Instala\xe7\xe3o cancelada; vers\xe3o anterior preservada')
    notify = getattr(IO_CONTEXT, 'notify', None)
    if notify is not None:
        notify(label, current, total)


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        raise RuntimeError('Redirecionamento recusado na distribui\xe7\xe3o do exportador')


def origin(value):
    parsed = urllib.parse.urlsplit(str(value).strip())
    if (parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username
            or parsed.password or parsed.path not in ('', '/') or parsed.query or parsed.fragment):
        raise ValueError('Informe somente a origem da API, sem caminho, usu\xe1rio ou senha')
    if parsed.scheme == 'http' and parsed.hostname not in ('localhost', '127.0.0.1', '::1'):
        raise ValueError('O Portal remoto deve usar HTTPS')
    host = parsed.hostname.lower()
    host = '[' + host + ']' if ':' in host else host
    port = parsed.port
    suffix = ':' + str(port) if port and port != (443 if parsed.scheme == 'https' else 80) else ''
    return parsed.scheme + '://' + host + suffix


def download(url, limit):
    parsed = urllib.parse.urlsplit(url)
    headers = {'Accept': 'application/octet-stream'}
    # Windows urllib tries ::1 serially. Docker's local HTTP listener may only
    # serve IPv4, costing a full socket timeout on every request to localhost.
    # Keep the logical origin/cache/Host intact; never rewrite HTTPS identities.
    if parsed.scheme == 'http' and parsed.hostname == 'localhost':
        headers['Host'] = parsed.netloc
        netloc = '127.0.0.1' + (':' + str(parsed.port) if parsed.port else '')
        url = urllib.parse.urlunsplit(parsed._replace(netloc=netloc))
    request = urllib.request.Request(url, headers=headers)
    # Distribution contains no credentials and never receives the Portal token.
    binary = url.endswith('.pyd')
    label = 'Baixando exportador compilado...' if binary else 'Consultando nova vers\xe3o no Portal...'
    report(label)
    deadline = time.monotonic() + (60 if binary else 10)
    chunks, size = [], 0
    with urllib.request.build_opener(NoRedirect()).open(request, timeout=5) as response:
        while True:
            report(label, size, limit if binary else 0)
            if time.monotonic() >= deadline:
                raise TimeoutError('Tempo limite ao consultar/baixar o exportador')
            chunk = response.read1(min(65536, limit + 1 - size))
            if not chunk:
                break
            chunks.append(chunk)
            size += len(chunk)
            if size > limit:
                raise RuntimeError('Download excede o limite permitido')
    report(label, size, limit if binary else 0)
    return b''.join(chunks)


def validate_release(value):
    if (value.get('schemaVersion') != 2 or value.get('aimsunVersion') != '26.0.1'
            or value.get('pythonTag') != 'cp312' or value.get('platform') != 'win_amd64'):
        raise RuntimeError('Distribui\xe7\xe3o incompat\xedvel com Aimsun 26.0.1 / Python 3.12 x64')
    module = value.get('moduleName', '')
    if not re.fullmatch(r'ExportarRedeCore_[a-f0-9]{32}', module):
        raise RuntimeError('Nome do m\xf3dulo inv\xe1lido')
    if value.get('filename') != module + '.cp312-win_amd64.pyd':
        raise RuntimeError('Nome do bin\xe1rio inv\xe1lido')
    if not re.fullmatch(r'[a-f0-9]{64}', value.get('sha256', '')):
        raise RuntimeError('SHA-256 inv\xe1lido')
    size = value.get('size')
    if type(size) is not int or not 256 <= size <= MAX_BINARY_BYTES:
        raise RuntimeError('Tamanho do bin\xe1rio inv\xe1lido')
    parsed = urllib.parse.urlsplit(value.get('downloadUrl', ''))
    origin(urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', '')))
    if parsed.query or parsed.fragment or parsed.path != '/downloads/aimsun/' + value['filename']:
        raise RuntimeError('Caminho de distribui\xe7\xe3o inv\xe1lido')
    return value


def validate_binary(data, release):
    if len(data) != release['size'] or hashlib.sha256(data).hexdigest() != release['sha256']:
        raise RuntimeError('Bin\xe1rio diverge do SHA-256/tamanho publicado; vers\xe3o anterior preservada')
    pe_offset = struct.unpack_from('<I', data, 0x3c)[0]
    if (data[:2] != b'MZ' or pe_offset + 6 > len(data)
            or data[pe_offset:pe_offset + 4] != b'PE\0\0'
            or struct.unpack_from('<H', data, pe_offset + 4)[0] != 0x8664):
        raise RuntimeError('O arquivo n\xe3o \xe9 uma extens\xe3o Windows x64 v\xe1lida')


def atomic_write(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix='.pending-', dir=path.parent)
    try:
        with os.fdopen(fd, 'wb') as output:
            output.write(data)
            output.flush()
            os.fsync(output.fileno())
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def cache_root(api):
    base = Path(os.environ.get('LOCALAPPDATA') or Path.home()) / 'Fratar' / 'AimsunExporter'
    return base / hashlib.sha256(api.encode()).hexdigest()[:16]


def binary_path(root, release):
    return root / release['sha256'] / release['filename']


def cached_release(root, receipt='active.json'):
    try:
        release = validate_release(json.loads((root / receipt).read_text(encoding='utf-8')))
        validate_binary(binary_path(root, release).read_bytes(), release)
        return release
    except (OSError, ValueError, RuntimeError, KeyError, TypeError, struct.error):
        return None


def fetch_update(api, root):
    release = validate_release(json.loads(download(api + '/api/via/aimsun/exporter-release', 16384)))
    if api == PORTAL_API_URL:
        parsed = urllib.parse.urlsplit(release['downloadUrl'])
        if origin(urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))) != PORTAL_API_URL:
            raise RuntimeError('O bin\xe1rio oficial deve vir da origem StreetWorks')
    path = binary_path(root, release)
    if path.exists():
        report('Verificando integridade do exportador instalado...')
        validate_binary(path.read_bytes(), release)
    else:
        data = download(release['downloadUrl'], release['size'])
        report('Verificando integridade do download...')
        validate_binary(data, release)
        report('Instalando exportador compilado...')
        atomic_write(path, data)
    return release


def load_core(root, release):
    path = binary_path(root, release)
    validate_binary(path.read_bytes(), release)
    name = release['moduleName']
    if name not in sys.modules:
        spec = importlib.util.spec_from_file_location(name, path)
        module = importlib.util.module_from_spec(spec)
        sys.modules[name] = module
        try:
            spec.loader.exec_module(module)
        except BaseException:
            sys.modules.pop(name, None)
            raise
    module = sys.modules[name]
    if module.EXPORTAR_REDE_VERSION != release['version'] or not callable(module.run_gui):
        raise RuntimeError('O m\xf3dulo carregado n\xe3o corresponde \xe0 vers\xe3o publicada')
    # Activate only after a successful native import. Older binaries remain intact.
    atomic_write(root / 'active.json', (json.dumps(release) + '\n').encode())
    return module


def load_installed(root):
    # Only disk access here: an unavailable Portal must never delay opening.
    for receipt in ('pending.json', 'active.json'):
        release = cached_release(root, receipt)
        if release is not None:
            try:
                return load_core(root, release)
            except Exception:
                # A downloaded DLL may fail to import; keep the proven version.
                continue
    return None


def start_background_update(api, root, application):
    # Retain jobs across repeated script executions. No Qt or native import in
    # this daemon: closing the dialog/Aimsun never waits for the network.
    jobs = getattr(application, '_aimsun_exporter_downloads', None)
    if jobs is None:
        jobs = application._aimsun_exporter_downloads = {}
    existing = jobs.get(api)
    if existing is not None and existing.is_alive():
        return existing
    def prepare():
        try:
            release = fetch_update(api, root)
            atomic_write(root / 'pending.json', (json.dumps(release) + '\n').encode())
        except Exception:
            # Optional check: the user is already using a verified local module.
            # The explicit update button reports connectivity errors on demand.
            pass
    worker = threading.Thread(target=prepare, name='AimsunExporterUpdate', daemon=True)
    jobs[api] = worker
    worker.start()
    return worker


def run_io(callback, parent=None):
    from PySide6 import QtCore, QtWidgets
    class Worker(QtCore.QThread):
        progress = QtCore.Signal(str, int, int)

        def run(self):
            self.error = None
            IO_CONTEXT.notify = self.progress.emit
            IO_CONTEXT.cancel = cancel
            try:
                self.result = callback()
            except BaseException as exc:
                self.error = exc
            finally:
                IO_CONTEXT.__dict__.clear()
    cancel = threading.Event()
    worker = Worker()
    loop = QtCore.QEventLoop()
    progress = QtWidgets.QProgressDialog('Consultando nova vers\xe3o no Portal...', 'Cancelar', 0, 0, parent)
    progress.setWindowTitle('Exportar Rede - Aimsun 26.0.0 / 26.0.1')
    progress.setAutoClose(False)
    progress.setAutoReset(False)
    progress.setWindowModality(QtCore.Qt.ApplicationModal)
    progress.setMinimumDuration(400)
    progress.canceled.connect(cancel.set)
    @QtCore.Slot(str, int, int)
    def update_progress(label, current, total):
        if not cancel.is_set():
            if total:
                label += ' {:.1f} / {:.1f} MB'.format(current / 1048576, total / 1048576)
            progress.setLabelText(label)
            progress.setRange(0, total)
            progress.setValue(current)
    worker.progress.connect(update_progress)
    worker.finished.connect(loop.quit)
    worker.start()
    loop.exec()
    worker.wait()
    progress.canceled.disconnect(cancel.set)
    progress.close()
    if worker.error is not None:
        raise worker.error
    if cancel.is_set():
        raise DownloadCancelled('Instala\xe7\xe3o cancelada; vers\xe3o anterior preservada')
    return worker.result


def run():
    from PySide6 import QtWidgets
    from PyANGKernel import GKSystem
    if (sys.platform != 'win32' or sys.version_info[:2] != (3, 12) or struct.calcsize('P') != 8
            or not re.search(r'(?<![0-9])26\.0\.[01](?![0-9.])', str(GKSystem.getSystem().getAppVersion()))):
        raise RuntimeError('Use este carregador no Aimsun Next 26.0.0 ou 26.0.1, Windows x64, Python 3.12')
    application = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
    # Public distribution is independent of saved Portal URLs and credentials.
    api = PORTAL_API_URL
    root = cache_root(api)
    module = load_installed(root)
    if module is None:
        try:
            release = run_io(lambda: fetch_update(api, root))
            module = load_core(root, release)
        except DownloadCancelled:
            return
        except Exception as exc:
            QtWidgets.QMessageBox.warning(None, 'Exportar Rede', 'N\xe3o foi poss\xedvel instalar o exportador.\nAPI: {}\n{}\nConfira a conex\xe3o com o StreetWorks oficial.'.format(api, exc))
            return
    else:
        start_background_update(api, root, application)
    def update_from_dialog(parent):
        target_api = PORTAL_API_URL
        target_root = cache_root(target_api)
        def fetch():
            job = getattr(application, '_aimsun_exporter_downloads', {}).get(target_api)
            while job is not None and job.is_alive():
                report('Concluindo verifica\xe7\xe3o em segundo plano...')
                job.join(.1)
            latest = fetch_update(target_api, target_root)
            atomic_write(target_root / 'pending.json', (json.dumps(latest) + '\n').encode())
            return latest
        try:
            latest = run_io(fetch, parent)
        except DownloadCancelled:
            return
        load_core(target_root, latest)
        current = getattr(module, '__name__', '')
        message = ('Voc\xea j\xe1 est\xe1 na vers\xe3o publicada.' if latest['moduleName'] == current else
                   'Nova vers\xe3o instalada: {}.\nFeche esta janela e execute novamente o carregador. O Aimsun pode permanecer aberto.'.format(latest['version']))
        QtWidgets.QMessageBox.information(parent, 'Atualiza\xe7\xe3o do exportador', message)
    configure_core(module)
    module._EXPORTER_BINARY_UPDATER = update_from_dialog
    for key in ('target', 'selection', 'argv'):
        if key in globals():
            setattr(module, key, globals()[key])
    module.run_gui()


def configure_core(module):
    """Apply public-origin policy without modifying the verified native binary."""
    if getattr(module, '_STREETWORKS_LOADER_CONFIGURED', False):
        return
    original = Path(module.PORTAL_SETTINGS_PATH)
    canonical = original.with_name(original.stem + '.streetworks' + original.suffix)
    initialize = module.ExportDialog.__init__

    def settings_load():
        # Do not call the legacy loader: its plaintext-token migration writes
        # the original file. Read it only to seed preferences on first use.
        source = canonical if canonical.exists() else original
        try:
            data = json.loads(source.read_text(encoding='utf-8'))
        except FileNotFoundError:
            data = {}
        except (OSError, ValueError) as exc:
            raise RuntimeError('Nao foi possivel ler as preferencias do exportador: {}'.format(exc)) from exc
        if not isinstance(data, dict):
            raise RuntimeError('Formato invalido das preferencias do exportador')
        data.pop('api_token', None)
        if source == original:
            # First adoption defaults to the official API without inheriting an
            # old Portal endpoint. Later explicit choices remain editable/saved.
            data['portal_url'] = PORTAL_API_URL
            credentials = data.get('credentials')
            data['credentials'] = ({PORTAL_API_URL: credentials[PORTAL_API_URL]}
                                   if isinstance(credentials, dict) and PORTAL_API_URL in credentials else {})
        else:
            data['portal_url'] = data.get('portal_url') or PORTAL_API_URL
            if not isinstance(data.get('credentials'), dict):
                data['credentials'] = {}
        return data

    def initialize_canonical(self, *args, **kwargs):
        initialize(self, *args, **kwargs)
        self.edit_portal_url.setReadOnly(False)
        self.edit_portal_url.setToolTip('URL editavel. Padrao oficial: ' + PORTAL_API_URL)

    module.DEFAULT_PORTAL_API_URL = PORTAL_API_URL
    # The existing atomic saver/DPAPI remain in charge of NEW StreetWorks
    # settings; legacy preferences and credentials are never rewritten.
    module.PORTAL_SETTINGS_PATH = str(canonical)
    module._portal_settings_load = settings_load
    module.ExportDialog.__init__ = initialize_canonical
    module._STREETWORKS_LOADER_CONFIGURED = True


if __name__ == '__main__':
    run()
