from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - Python >=3.11 is documented
    tomllib = None


ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG = ROOT / ".hil" / "local.toml"
ARTIFACT_ROOT = ROOT / "artifacts" / "hil"
HIL_PREFIX = b"HIL:"


class HilError(RuntimeError):
    pass


@dataclass(frozen=True)
class PortConfig:
    name: str
    device: Path
    approved_access: bool
    approved_flash: bool = False


def utc_stamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def new_artifact_dir(label: str = "run") -> Path:
    path = ARTIFACT_ROOT / f"{utc_stamp()}-{label}"
    suffix = 1
    while path.exists():
        path = ARTIFACT_ROOT / f"{utc_stamp()}-{label}-{suffix}"
        suffix += 1
    path.mkdir(parents=True)
    return path


def load_config(path: Path = DEFAULT_CONFIG) -> dict[str, Any]:
    if tomllib is None:
        raise HilError("Python 3.11 or newer is required (tomllib is unavailable)")
    if not path.is_file():
        raise HilError(f"missing {path}; copy .hil/local.example.toml and enter only approved ports")
    with path.open("rb") as handle:
        config = tomllib.load(handle)
    return config


def load_config_if_present(path: Path = DEFAULT_CONFIG) -> dict[str, Any]:
    return load_config(path) if path.is_file() else {}


def configured_port(config: dict[str, Any], name: str, operation: str) -> PortConfig:
    raw = config.get("ports", {}).get(name, {})
    device_text = str(raw.get("device", "")).strip()
    if not device_text:
        raise HilError(f"ports.{name}.device is not configured")
    device = Path(device_text)
    if not str(device).startswith("/dev/serial/by-id/"):
        raise HilError(f"{name} must use a stable /dev/serial/by-id path, got {device}")
    if not device.exists():
        raise HilError(f"configured {name} device does not exist: {device}")
    access = raw.get("approved_access") is True
    flash = raw.get("approved_flash") is True
    if operation in {"access", "flash"} and not access:
        raise HilError(f"first access to {device} is not approved in {DEFAULT_CONFIG}")
    if operation == "flash" and not flash:
        raise HilError(f"flashing {device} is not approved in {DEFAULT_CONFIG}")
    return PortConfig(name, device, access, flash)


def pair_ports(config: dict[str, Any], operation: str) -> tuple[PortConfig, PortConfig]:
    sender = configured_port(config, "sender", operation)
    receiver = configured_port(config, "receiver", operation)
    if sender.device.resolve() == receiver.device.resolve():
        raise HilError("sender and receiver resolve to the same device")
    return sender, receiver


def pio_command() -> str:
    for candidate in (shutil.which("pio"), shutil.which("platformio")):
        if candidate:
            return candidate
    local = Path.home() / ".platformio" / "penv" / "bin" / "pio"
    if local.is_file() and os.access(local, os.X_OK):
        return str(local)
    raise HilError("PlatformIO CLI was not found on PATH or in ~/.platformio/penv/bin/pio")


def run_checked(command: list[str], *, cwd: Path = ROOT, log: Path | None = None) -> str:
    process = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT, check=False)
    if log:
        log.parent.mkdir(parents=True, exist_ok=True)
        log.write_text(process.stdout, encoding="utf-8")
    sys.stdout.write(process.stdout)
    if process.returncode:
        raise HilError(f"command failed ({process.returncode}): {' '.join(command)}")
    return process.stdout


def parse_hil_line(line: bytes) -> dict[str, Any] | None:
    marker = line.find(HIL_PREFIX)
    if marker < 0:
        return None
    try:
        value = json.loads(line[marker + len(HIL_PREFIX):].decode("utf-8", "strict").strip())
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None
    return value if isinstance(value, dict) and isinstance(value.get("event"), str) else None


def serial_module():
    try:
        import serial
    except ModuleNotFoundError as exc:
        raise HilError("pyserial is required; install requirements-hil.txt in a project-local virtualenv") from exc
    return serial


class PairCapture:
    def __init__(self, ports: tuple[PortConfig, PortConfig], baud: int, output: Path):
        self.ports = ports
        self.baud = baud
        self.output = output
        self.stop = threading.Event()
        self.events: list[dict[str, Any]] = []
        self._lock = threading.Lock()
        self._handles: dict[str, Any] = {}
        self._threads: list[threading.Thread] = []

    def _reader(self, port: PortConfig) -> None:
        raw_path = self.output / f"{port.name}.raw"
        log_path = self.output / f"{port.name}.log"
        handle = self._handles[port.name]
        with raw_path.open("ab", buffering=0) as raw, log_path.open("a", encoding="utf-8", buffering=1) as log:
            while not self.stop.is_set():
                line = handle.readline()
                if not line:
                    continue
                host_ns = time.time_ns()
                raw.write(line)
                rendered = line.decode("utf-8", "replace").rstrip("\r\n")
                log.write(f"{datetime.fromtimestamp(host_ns / 1e9, timezone.utc).isoformat()} {rendered}\n")
                event = parse_hil_line(line)
                if event is not None:
                    event["host_time_ns"] = host_ns
                    event["source"] = port.name
                    with self._lock:
                        self.events.append(event)

    def start(self) -> None:
        serial = serial_module()
        self.output.mkdir(parents=True, exist_ok=True)
        try:
            for port in self.ports:
                # Opening is deliberately delayed until approval checks have completed.
                self._handles[port.name] = serial.Serial(str(port.device), self.baud, timeout=0.25,
                                                         write_timeout=1, exclusive=True)
            for port in self.ports:
                thread = threading.Thread(target=self._reader, args=(port,), daemon=True,
                                          name=f"hil-{port.name}")
                thread.start()
                self._threads.append(thread)
        except Exception:
            self.close()
            raise

    def send_fault(self, target: str, fault: str) -> None:
        allowed = {"drop_chunk", "duplicate_chunk", "corrupt_chunk", "suppress_ack",
                   "wrong_ack_batch", "clear"}
        if fault not in allowed and not fault.startswith("delay_ack:"):
            raise HilError(f"unsupported HIL fault: {fault}")
        handle = self._handles.get(target)
        if handle is None:
            raise HilError(f"serial target is not open: {target}")
        handle.write(f"HILCMD:{fault}\n".encode("ascii"))
        handle.flush()

    def reset_pair(self) -> None:
        """Pulse ESP32 EN through the normal USB auto-reset circuit.

        DTR remains deasserted so GPIO0 is not held low and the boards boot the
        flashed application rather than the ROM download mode.
        """
        for handle in self._handles.values():
            handle.dtr = False
            handle.rts = True
        time.sleep(0.15)
        for handle in self._handles.values():
            handle.rts = False
        time.sleep(0.25)

    def close(self) -> None:
        self.stop.set()
        for thread in self._threads:
            thread.join(timeout=2)
        for handle in self._handles.values():
            try:
                handle.close()
            except Exception:
                pass
        self._handles.clear()
        ordered = sorted(self.events, key=lambda item: item["host_time_ns"])
        with (self.output / "events.jsonl").open("w", encoding="utf-8") as stream:
            for event in ordered:
                stream.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n")

    def run(self, seconds: float, on_tick: Callable[[float, "PairCapture"], None] | None = None,
            reset_on_start: bool = False) -> None:
        self.start()
        if reset_on_start:
            self.reset_pair()
        started = time.monotonic()
        try:
            while time.monotonic() - started < seconds and not self.stop.is_set():
                elapsed = time.monotonic() - started
                if on_tick:
                    on_tick(elapsed, self)
                time.sleep(0.2)
        except KeyboardInterrupt:
            pass
        finally:
            self.close()


def read_events(artifact: Path) -> list[dict[str, Any]]:
    path = artifact / "events.jsonl"
    if not path.is_file():
        return []
    events = []
    for line in path.read_text(encoding="utf-8").splitlines():
        try:
            item = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(item, dict):
            events.append(item)
    return events


def main_guard(function: Callable[[], int]) -> None:
    try:
        raise SystemExit(function())
    except HilError as exc:
        print(f"HIL error: {exc}", file=sys.stderr)
        raise SystemExit(2)
