"""PilotNG Web-UI: lokaler Server fuer die Browser-Oberflaeche.

Liest ausschliesslich die Live-Statusdateien des Bots (kein Zugriff
auf den Loop) und nimmt Befehle als Datei entgegen
(.pilotng-web-command.json), die der Mainloop konsumiert. Bindet aus
Sicherheitsgruenden nur auf 127.0.0.1 — kein Netzwerk-Zugriff.

ponytail: Befehle ohne Token, weil localhost-only (Upgrade-Trigger:
Token-Datei, sobald die UI uebers Netz erreichbar sein soll).
"""
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

from src.gameplay.death import DEFAULT_DEATH_CONFIG
from src.ui import livestate

HOST = '127.0.0.1'
PORT = 8620
COMMAND_FILE = Path('.pilotng-web-command.json')
WEBUI_DIR = Path(__file__).parent
REPO_DIR = WEBUI_DIR.parent

ALLOWED_ACTIONS = {
    'play', 'pause', 'toggleCavebot', 'toggleRunToCreatures',
    'toggleHealing', 'toggleCombo', 'toggleRecording', 'setConfig',
}

# Aktionen, die ein Windows-Skript/eine Aufgabe ausloesen (nicht den Loop).
TASK_ACTIONS = {
    # Ueber den Wrapper, NICHT per schtasks direkt: Windows ignoriert einen
    # Start still, wenn die Aufgabe noch als "laeuft" gilt (Karteileiche
    # eines abgebrochenen Laufs). Der Wrapper loest das auf und startet
    # ausserdem neu, wenn der Bildschirm gewechselt wurde.
    'startAll': ['powershell.exe', '-NoProfile', '-NonInteractive',
                 '-ExecutionPolicy', 'Bypass', '-File',
                 str(REPO_DIR / 'tools' / 'Start-OneClick-Task.ps1')],
    'stopBot': ['powershell.exe', '-NoProfile', '-NonInteractive',
                '-ExecutionPolicy', 'Bypass', '-File',
                str(REPO_DIR / 'tools' / 'Stop-TibiaPilotNG.ps1')],
    # Beendet zusaetzlich Tibia, OBS und die Projektor-Fenster.
    'stopAll': ['powershell.exe', '-NoProfile', '-NonInteractive',
                '-ExecutionPolicy', 'Bypass', '-File',
                str(REPO_DIR / 'tools' / 'Stop-Everything.ps1')],
    'installUpdate': ['powershell.exe', '-NoProfile', '-NonInteractive',
                      '-ExecutionPolicy', 'Bypass', '-File',
                      str(REPO_DIR / 'tools' / 'Update-TibiaPilotNG.ps1'),
                      '-ProcessId', '0'],
}

# Rohstatus aus .one-click.log -> Klartext fuer die Oberflaeche.
STARTUP_TEXT = {
    'start': 'Startvorgang läuft …',
    'arduino-flash': 'Arduino wird programmiert …',
    'hid-ready': 'Eingabe-Hardware bereit',
    'obs-start': 'OBS wird gestartet …',
    'client-launch': 'Tibia-Client wird gestartet …',
    'obs-projector': 'Bildschirm-Aufnahme wird eingerichtet …',
    'minimap-zoom': 'Karte wird eingestellt …',
    'bot-start': 'Bot wird gestartet …',
    'running': 'Alles läuft',
    'normal-profile-restored': 'Beendet — Profil zurückgesetzt.',
}

STATIC_TYPES = {
    'index.html': 'text/html; charset=utf-8',
    'app.css': 'text/css; charset=utf-8',
    'app.js': 'application/javascript; charset=utf-8',
}


def _healingEnabled(config):
    healing = config.get('healing', {})
    potions = healing.get('potions', {})
    spells = healing.get('spells', {})
    return (
        any(p.get('enabled') for p in potions.values())
        or any(s.get('enabled') for s in spells.values())
        or healing.get('eatFood', {}).get('enabled', False)
    )


def _profileConfig(path='file.json'):
    """Aktives Profil aus der TinyDB-Datei."""
    try:
        data = json.loads(Path(path).read_text(encoding='utf-8'))
        for doc in data.get('_default', {}).values():
            if doc.get('enabled'):
                return doc.get('config', {})
    except (OSError, ValueError, AttributeError):
        pass
    return {}


def buildState(profile_path='file.json', state_path=livestate.STATE_FILE,
               session_path=livestate.SESSION_FILE,
               stats_path=livestate.STATS_FILE,
               events_path=livestate.EVENTS_FILE):
    """Aggregiert alles, was das Dashboard anzeigt, aus den Dateien."""
    state = livestate.readState(state_path)
    session = livestate.sessionSummary(session_path)
    config = _profileConfig(profile_path)
    return {
        'pause': state.get('pause', True),
        'task': state.get('task'),
        'waypointIndex': state.get('waypointIndex'),
        'coordinate': state.get('coordinate'),
        'hpPercentage': state.get('hpPercentage'),
        'manaPercentage': state.get('manaPercentage'),
        'toggles': {
            'cavebot': bool(state.get('cavebotEnabled')),
            'runToCreatures': bool(state.get('runToCreatures')),
            'combo': bool(state.get('comboEnabled')),
            'healing': _healingEnabled(config),
        },
        'playedSeconds': session['playedSeconds'],
        'breakRemainingSeconds': session['breakRemainingSeconds'],
        'stats': livestate.readStats(stats_path),
        'events': livestate.readEvents(events_path, limit=8),
    }


def buildConfig(profile_path='file.json'):
    """Alle konfigurierbaren Bereiche fuer die Einstellungsseiten."""
    config = _profileConfig(profile_path)
    death = DEFAULT_DEATH_CONFIG.copy()
    for key in death:
        stored = (config.get('ng_deathConfig') or {}).get(key)
        if stored is not None:
            death[key] = stored
    cave = config.get('ng_cave', {})
    return {
        'death': death,
        'healing': config.get('healing', {}),
        'design': {
            'theme': config.get('ui_theme', 'rosa'),
            'mode': config.get('ui_mode', 'dunkel'),
            'glass': int(config.get('ui_glass', 100)),
        },
        'hotkeys': config.get('general_hotkeys', {}),
        'autoHur': config.get('auto_hur', {}),
        'alert': config.get('alert', {}),
        'allies': config.get('allies', []),
        'enemies': config.get('enemies', []),
        'backpacks': config.get('ng_backpacks', {}),
        'lootRules': config.get('ng_lootRules', []),
        'lootDropUnwanted': bool(config.get('ng_lootDropUnwanted', False)),
        'combos': [
            {'name': combo.get('name', '?'),
             'enabled': bool(combo.get('enabled')),
             'spells': len(combo.get('spells', []))}
            for combo in config.get('ng_comboSpells', {}).get('items', [])
        ],
        'waypoints': [
            {'label': wp.get('label', ''), 'type': wp.get('type', ''),
             'coordinate': wp.get('coordinate')}
            for wp in cave.get('waypoints', {}).get('items', [])
        ],
    }


def loadAccount():
    """Nur die Account-Adresse — das Passwort bleibt im Tresor."""
    try:
        from src.security.credentials import load_credentials
        existing = load_credentials() or {}
        return {'account': existing.get('account', '')}
    except (OSError, ValueError):
        return {'account': ''}


def saveAccount(account, password):
    """Speichert DPAPI-verschluesselt; leeres Passwort = altes behalten."""
    from src.security.credentials import load_credentials, save_credentials
    existing = load_credentials() or {}
    save_credentials(account, password or existing.get('password', ''))


def readTextFile(path):
    """PowerShell schreibt mal UTF-8 (mit BOM), mal ANSI — beides lesen."""
    try:
        raw = Path(path).read_bytes()
    except OSError:
        return None
    for encoding in ('utf-8-sig', 'cp1252'):
        try:
            return raw.decode(encoding)
        except UnicodeDecodeError:
            continue
    return raw.decode('utf-8', errors='replace')


def buildStartup(repo=REPO_DIR):
    """Startfortschritt des One-Click-Vollstarts fuer die Oberflaeche."""
    raw = readTextFile(repo / '.one-click-result.json')
    if raw:
        try:
            result = json.loads(raw)
            if not result.get('ok', True):
                return {'state': 'error',
                        'text': result.get('message', 'Start fehlgeschlagen.')}
        except ValueError:
            pass
    text = readTextFile(repo / '.one-click.log')
    lines = [line for line in (text or '').splitlines() if line.strip()]
    if not lines:
        return {'state': 'idle', 'text': 'Noch kein Vollstart gelaufen.'}
    parts = lines[-1].split(' ', 1)
    raw = (parts[1] if len(parts) > 1 else parts[0]).strip()
    at = parts[0][11:19] if len(parts[0]) >= 19 else ''
    if raw.startswith('error='):
        return {'state': 'error', 'text': raw[6:], 'at': at}
    state = 'running' if raw == 'running' else (
        'done' if raw == 'normal-profile-restored' else 'busy')
    return {'state': state, 'text': STARTUP_TEXT.get(raw, raw), 'at': at}


def buildHardware():
    """Bildschirme + gewaehlte Eingabe-Methode fuer die Hardware-Karte."""
    displays, selected, error = [], None, None
    try:
        from src.utils import display as displayUtils
        displays = displayUtils.getDisplays()
        try:
            selected = displayUtils.getSelectedDisplay()['device']
        except RuntimeError:
            selected = None
    except Exception as failure:  # noqa: BLE001 — UI darf nie hart fallen
        error = str(failure)
    return {
        'displays': displays,
        'selected': selected,
        'inputMethod': readInputMethod(),
        'error': error,
    }


def readInputMethod(repo=REPO_DIR):
    try:
        value = (repo / '.input-method').read_text(
            encoding='utf-8').strip().lower()
    except OSError:
        return 'usb'
    return value if value in ('usb', 'mac_bt') else 'usb'


def writeInputMethod(value, repo=REPO_DIR):
    if value not in ('usb', 'mac_bt'):
        raise ValueError('Unbekannte Eingabe-Methode.')
    (repo / '.input-method').write_text(value, encoding='ascii')
    return value


def selectDisplay(device):
    from src.utils import display as displayUtils
    return displayUtils.saveSelectedDisplay(device)


def checkHid():
    """HID-Status pruefen (kann 1-2 s dauern, daher eigener Endpunkt)."""
    from src.utils.ino import ArduinoUnavailableError, requireArduino
    try:
        requireArduino()
    except ArduinoUnavailableError as error:
        return {'ok': False, 'message': str(error)}
    except Exception as error:  # noqa: BLE001
        return {'ok': False, 'message': str(error)}
    return {'ok': True, 'message': 'Eingabe-Hardware antwortet.'}


def runTask(action):
    """Windows-Skript/Aufgabe starten (ohne den Bot-Loop zu beruehren)."""
    import subprocess
    command = TASK_ACTIONS[action]
    subprocess.Popen(command, cwd=str(REPO_DIR), creationflags=0x08000000)


def _indexWithVersions():
    """index.html mit ?v=<mtime> an app.css/app.js — killt WebView2-Cache.

    So sieht der Nutzer nach jedem Update sofort den neuen Stand, ohne
    Cache-Leerung; die Version ist die Datei-Aenderungszeit.
    """
    html = (WEBUI_DIR / 'index.html').read_text(encoding='utf-8')
    for name in ('app.css', 'app.js'):
        try:
            version = int((WEBUI_DIR / name).stat().st_mtime)
        except OSError:
            version = 0
        html = html.replace(f'/{name}"', f'/{name}?v={version}"')
    return html.encode('utf-8')


def writeCommand(action, value=None, file=COMMAND_FILE, **extra):
    payload = {'action': action, 'value': value}
    payload.update(extra)
    Path(file).write_text(json.dumps(payload), encoding='utf-8')


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # kein Log-Spam

    def _send(self, code, body, contentType):
        data = body if isinstance(body, bytes) else body.encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', contentType)
        self.send_header('Content-Length', str(len(data)))
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        self.wfile.write(data)

    def _json(self, code, payload):
        self._send(code, json.dumps(payload), 'application/json')

    def do_GET(self):
        if self.path in ('/', '/index.html'):
            self._send(200, _indexWithVersions(),
                       'text/html; charset=utf-8')
        elif self.path.split('?')[0].lstrip('/') in STATIC_TYPES:
            name = self.path.split('?')[0].lstrip('/')
            self._send(200, (WEBUI_DIR / name).read_bytes(),
                       STATIC_TYPES[name])
        elif self.path == '/api/state':
            self._json(200, buildState())
        elif self.path == '/api/config':
            self._json(200, buildConfig())
        elif self.path == '/api/startup':
            self._json(200, buildStartup())
        elif self.path == '/api/hardware':
            self._json(200, buildHardware())
        elif self.path == '/api/hid':
            self._json(200, checkHid())
        elif self.path == '/api/account':
            self._json(200, loadAccount())
        else:
            self._json(404, {'error': 'not found'})

    def do_POST(self):
        try:
            length = int(self.headers.get('Content-Length', 0))
            payload = json.loads(self.rfile.read(length) or b'{}')
        except (ValueError, OSError):
            payload = {}
        if self.path == '/api/account':
            try:
                saveAccount(payload.get('account', ''),
                            payload.get('password', ''))
            except (OSError, ValueError) as error:
                return self._json(400, {'ok': False, 'error': str(error)})
            return self._json(200, {'ok': True})
        if self.path == '/api/hardware':
            try:
                if 'display' in payload:
                    selectDisplay(payload['display'])
                if 'inputMethod' in payload:
                    writeInputMethod(payload['inputMethod'])
            except (OSError, ValueError, RuntimeError) as error:
                return self._json(400, {'ok': False, 'error': str(error)})
            return self._json(200, {'ok': True, **buildHardware()})
        if self.path != '/api/command':
            return self._json(404, {'error': 'not found'})
        action = payload.get('action')
        if action in TASK_ACTIONS:
            try:
                runTask(action)
            except OSError as error:
                return self._json(400, {'ok': False, 'error': str(error)})
            return self._json(200, {'ok': True})
        if action not in ALLOWED_ACTIONS:
            return self._json(400, {'ok': False})
        extra = {key: payload[key] for key in ('path',) if key in payload}
        writeCommand(action, payload.get('value'), **extra)
        self._json(200, {'ok': True})


def main():
    server = ThreadingHTTPServer((HOST, PORT), Handler)
    print(f'PilotNG Web-UI laeuft auf http://{HOST}:{PORT}')
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == '__main__':
    main()
