"""Tiny GUI client for the headless AAhead backend.

Live backend is opt-in through AAHEAD_LIVE_BACKEND=1. Every call has a short
timeout and returns None on failure so screens can keep their existing mocks.
"""

from __future__ import annotations

import json
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Any


def live_enabled() -> bool:
    return os.environ.get("AAHEAD_LIVE_BACKEND", "0").lower() in ("1", "true", "yes", "on")


def aactl_path() -> str:
    override = os.environ.get("AAHEAD_AACTL")
    if override:
        return override
    return str(Path(__file__).resolve().parents[2] / "aactl")


def gui_log(event: str, **fields: Any) -> None:
    payload = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "event": event, **fields}
    try:
        print("AAHEAD_BACKEND " + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), flush=True)
    except Exception:
        pass


def call(*args: str, timeout: float = 2.0) -> dict[str, Any] | None:
    if not live_enabled():
        return None
    cmd = [aactl_path(), *args, "--json"]
    started = time.monotonic()
    gui_log("call.start", args=list(args), timeout=timeout)
    try:
        proc = subprocess.run(
            cmd,
            text=True,
            encoding="utf-8",
            errors="replace",
            capture_output=True,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        gui_log(
            "call.timeout",
            args=list(args),
            timeout=timeout,
            stdout=str(exc.stdout or "")[-500:],
            stderr=str(exc.stderr or "")[-500:],
        )
        return None
    except Exception as exc:
        gui_log("call.error", args=list(args), error=repr(exc))
        return None
    elapsed_ms = round((time.monotonic() - started) * 1000)
    if not proc.stdout:
        gui_log("call.no_stdout", args=list(args), rc=proc.returncode, stderr=proc.stderr[-500:], ms=elapsed_ms)
        return None
    try:
        data = json.loads(proc.stdout)
    except json.JSONDecodeError as exc:
        gui_log("call.bad_json", args=list(args), error=str(exc), stdout=proc.stdout[-500:], stderr=proc.stderr[-500:])
        return None
    if not isinstance(data, dict):
        gui_log("call.bad_shape", args=list(args), shape=type(data).__name__)
        return None
    if proc.returncode != 0 or not data.get("ok", True):
        gui_log(
            "call.result",
            args=list(args),
            rc=proc.returncode,
            ok=data.get("ok"),
            state=data.get("state"),
            title=data.get("title"),
            ms=elapsed_ms,
        )
    else:
        gui_log("call.done", args=list(args), state=data.get("state"), title=data.get("title"), ms=elapsed_ms)
    return data


class AsyncCall:
    """Run a backend call off the render thread."""

    def __init__(self, label: str, fn, *args: Any, **kwargs: Any):
        self.label = label
        self.result: Any = None
        self.error: str | None = None
        self.done = False
        self._thread = threading.Thread(target=self._run, args=(fn, args, kwargs), daemon=True)
        self._thread.start()

    def _run(self, fn, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
        started = time.monotonic()
        try:
            self.result = fn(*args, **kwargs)
        except Exception as exc:
            self.error = repr(exc)
            gui_log("async.error", label=self.label, error=self.error)
        finally:
            self.done = True
            gui_log("async.done", label=self.label, error=self.error, ms=round((time.monotonic() - started) * 1000))


def call_async(label: str, fn, *args: Any, **kwargs: Any) -> AsyncCall:
    return AsyncCall(label, fn, *args, **kwargs)


def status(timeout: float = 1.2) -> dict[str, Any] | None:
    return call("status", timeout=timeout)


def bluetooth_status() -> dict[str, Any] | None:
    return call("bt", "status", timeout=3.0)


def bluetooth_scan_async(duration: int = 15) -> dict[str, Any] | None:
    return call("bt", "scan", "--async", "--duration", str(duration), timeout=4.0)


def bluetooth_pair(mac: str) -> dict[str, Any] | None:
    return call("bt", "pair", mac, "--apply", timeout=4.0)


def bluetooth_connect(mac: str) -> dict[str, Any] | None:
    return call("bt", "connect", mac, "--apply", timeout=4.0)


def bluetooth_disconnect(mac: str) -> dict[str, Any] | None:
    return call("bt", "disconnect", mac, "--apply", timeout=4.0)


def bluetooth_forget(mac: str) -> dict[str, Any] | None:
    return call("bt", "forget", mac, "--apply", timeout=4.0)


def bluetooth_route(target: str) -> dict[str, Any] | None:
    return call("bt", "route", target, timeout=4.0)


def bluetooth_test_tone() -> dict[str, Any] | None:
    return call("bt", "test-tone", timeout=6.0)


def bluetooth_prepare_apply() -> dict[str, Any] | None:
    return call("bt", "prepare", "--apply", timeout=4.0)


def diagnostics_report() -> dict[str, Any] | None:
    return call("diagnostics", "report", timeout=4.0)


def diagnostics_bundle() -> dict[str, Any] | None:
    return call("diagnostics", "bundle", timeout=8.0)


def diagnostics_bundle_async() -> dict[str, Any] | None:
    return call("diagnostics", "bundle", "--async", timeout=3.0)


def job_status(job_id: str) -> dict[str, Any] | None:
    return call("job", "status", job_id, timeout=2.0)


# ── Wi-Fi (nmcli, in-process) ─────────────────────────────────────────
# Unlike Bluetooth — which routes through the heavyweight aactl job server —
# Wi-Fi talks to NetworkManager (`nmcli`) directly via gui/control/netops.py,
# the same in-shell, dev-tolerant pattern as save-state enumeration. These
# wrappers gate on the SAME master switch as the rest of Control
# (AAHEAD_LIVE_BACKEND): off → return None/sentinel so screens keep their demo
# data and simulated flows; on → real nmcli. netops itself degrades to None
# when nmcli is absent, so a live box without NetworkManager is still safe.


def wifi_adapter_present() -> bool | None:
    """True/False if a Wi-Fi adapter exists; None when not live (assume yes)."""
    if not live_enabled():
        return None
    from .. import netops
    return netops.adapter_present()


def wifi_status() -> dict[str, Any] | None:
    """Current connection (ssid/lead/band/ip/radio) or None to use demo hero."""
    if not live_enabled():
        return None
    from .. import netops
    res = netops.status()
    gui_log("wifi.status", connected=bool(res and res.get("connected")))
    return res


def wifi_scan(rescan: bool = True) -> list[dict] | None:
    """Network list (connected/saved/nearby flags merged) or None for demo."""
    if not live_enabled():
        return None
    from .. import netops
    res = netops.scan(rescan=rescan)
    gui_log("wifi.scan", rescan=rescan, count=(len(res) if res is not None else None))
    return res


def wifi_connect(ssid: str, password: str | None = None) -> dict | None:
    """{ok, error} on a real attempt; None when not live (screen simulates).
    The real nmcli error is logged to gui.log for off-device diagnosis."""
    if not live_enabled():
        return None
    from .. import netops
    res = netops.connect(ssid, password)
    gui_log("wifi.connect", ssid=ssid, has_password=bool(password),
            ok=res.get("ok"), error=res.get("error"))
    return res


def wifi_forget(ssid: str) -> bool | None:
    """True/False on a real delete; None when not live (screen simulates)."""
    if not live_enabled():
        return None
    from .. import netops
    ok = netops.forget(ssid)
    gui_log("wifi.forget", ssid=ssid, ok=ok)
    return ok


def wifi_set_radio(on: bool) -> bool | None:
    """True/False on a real toggle; None when not live (screen simulates)."""
    if not live_enabled():
        return None
    from .. import netops
    ok = netops.set_radio(on)
    gui_log("wifi.radio", on=on, ok=ok)
    return ok


# ── Storage / backup (tar, in-process) ────────────────────────────────
# Same in-shell pattern as Wi-Fi: replicate dArkOS's backup *command*
# (`sudo tar …`), never its interactive TUI script. Output is captured, so a
# backup never drops the user to a console; the screen renders its own
# progress. Gated on live_enabled() → dev box runs the screen's simulation.


def storage_free(path: str) -> int | None:
    """Free bytes on the fs holding `path`, or None (→ screen uses demo)."""
    if not live_enabled():
        return None
    from .. import storageops
    return storageops.disk_free(path)


def storage_second_sd() -> bool | None:
    """True/False if a 2nd SD card is mounted; None when not live (assume yes
    for the dev/preview simulation)."""
    if not live_enabled():
        return None
    from .. import storageops
    return storageops.second_sd_present()


def storage_latest_backup(target_dir: str) -> dict | None:
    """Newest backup archive {path,name,size,mtime}, or None."""
    if not live_enabled():
        return None
    from .. import storageops
    return storageops.latest_backup(target_dir)


def storage_backup(tier: str, target_dir: str) -> dict | None:
    """Run a real backup; returns {ok, archive, size, error} or None when not
    live (screen simulates). Blocking — call via call_async off the render
    thread."""
    if not live_enabled():
        return None
    from .. import storageops
    res = storageops.run_backup(tier, target_dir)
    gui_log("storage.backup", tier=tier, ok=bool(res and res.get("ok")),
            size=(res or {}).get("size"))
    return res


# ── Driver install (BT + Wi-Fi dongle modules) ───────────────────────
# The CSR-BT kernel fix ships dongle drivers; this installs them in-shell
# (copy → depmod → modprobe), console-free. Same gating as the rest.


def drivers_status() -> dict | None:
    """{package_present, installed, wlan_up} or None when not live."""
    if not live_enabled():
        return None
    from .. import driversops
    return {"package_present": driversops.find_package() is not None,
            "installed": driversops.driver_installed(),
            "wlan_up": driversops.wlan_up()}


def drivers_install() -> dict | None:
    """Install the bundled BT/Wi-Fi modules; {ok, wlan_up, needs_reboot, error}
    or None when not live (screen simulates). Blocking — call via call_async."""
    if not live_enabled():
        return None
    from .. import driversops
    res = driversops.run_install()
    gui_log("drivers.install", ok=bool(res and res.get("ok")),
            wlan_up=bool(res and res.get("wlan_up")))
    return res


# ── Power / performance (governor + sensors, sysfs in-process) ────────


def power_status() -> dict | None:
    """{governor, profile, available, battery, thermals} or None to use demo."""
    if not live_enabled():
        return None
    from .. import powerops
    return powerops.status()


def power_set_profile(profile: str) -> bool | None:
    """Apply a perf profile (writes the CPU/GPU governors). True/False on a real
    attempt; None when not live (screen simulates)."""
    if not live_enabled():
        return None
    from .. import powerops
    ok = powerops.set_governor(profile)
    gui_log("power.profile", profile=profile, ok=ok)
    return ok


def power_thermals() -> list | None:
    """Live thermal zones [{label, c}] (hottest first), or None when not live
    (the Temperature screen shows a calm demo)."""
    if not live_enabled():
        return None
    from .. import powerops
    res = powerops.thermals()
    gui_log("power.thermals", count=(len(res) if res is not None else None))
    return res


# ── Screen backlight (brightness) ─────────────────────────────────────
# The ONE real brightness control, shared by the in-game Quick Menu and the
# Display & Power screen. Gated like everything else; the device layer lives in
# gui.library.backlight so both the launcher and Control can reach it.


def display_brightness() -> int | None:
    """Current screen brightness 0..100, or None when not live / no backlight
    (the slider keeps its demo value)."""
    if not live_enabled():
        return None
    from ...library import backlight
    b = backlight.read()
    return b["pct"] if b else None


def display_set_brightness(pct: int) -> bool | None:
    """Write screen brightness (0..100). True/False on a real attempt; None
    when not live (the slider just moves)."""
    if not live_enabled():
        return None
    from ...library import backlight
    ok = backlight.set_pct(pct)
    gui_log("display.brightness", pct=pct, ok=ok)
    return ok


# ── System volume (ALSA) ──────────────────────────────────────────────
# The ONE real volume control, shared by the in-game Quick Menu and the
# Display & Sound screen. Gated like everything else; device layer in
# gui.library.volume.


def volume_get() -> int | None:
    """Current system volume 0..100, or None when not live / no mixer."""
    if not live_enabled():
        return None
    from ...library import volume
    return volume.read()


def volume_set(pct: int) -> bool | None:
    """Set the system volume (0..100). True/False on a real attempt; None when
    not live (the slider just moves)."""
    if not live_enabled():
        return None
    from ...library import volume
    ok = volume.set_pct(pct)
    gui_log("display.volume", pct=pct, ok=ok)
    return ok


# ── Audio route (speaker / bluetooth) ─────────────────────────────────
# Drives the csr-fix audio daemon's mode file directly (gui.control.audioops),
# NOT the heavyweight aactl job server — so switching to the built-in speaker
# works even though aactl isn't deployed to the AAhead port. Gated as usual.


def audio_route(target: str) -> bool | None:
    """Force the audio route ('speaker' / 'bluetooth' / 'auto'). True/False on a
    real attempt; None when not live (the screen just reflects the choice)."""
    if not live_enabled():
        return None
    from .. import audioops
    ok = audioops.set_route(target)
    gui_log("audio.route", target=target, ok=ok)
    return ok


def audio_route_status() -> str | None:
    """The live route ('speaker' / 'bluetooth'), or None when not live / no pulse."""
    if not live_enabled():
        return None
    from .. import audioops
    return audioops.current_route()


def audio_test_tone() -> bool | None:
    """Play a test tone through the current route (the built-in speaker by
    default — no Bluetooth required). True/False on a real attempt; None when
    not live. Blocking (~1.5s) — call via call_async off the render thread."""
    if not live_enabled():
        return None
    from .. import audioops
    ok = audioops.test_tone()
    gui_log("audio.test_tone", ok=ok)
    return ok
