"""Build, clean, flash, and bootloader-entry operations.

Design: pio is the preferred path for every architecture via `flash()`. For
ESP32 factory flashes we shell out to `bin/device-install.sh` (which knows
about partition offsets and the OTA/littlefs partitions); for ESP32 OTA
updates we use `bin/device-update.sh`. Both scripts require the build
artifacts to exist, so these tools build first if needed.
"""

from __future__ import annotations

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

import serial

from . import boards, config, connection, devices, pio, userprefs

# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
ESP32_ARCHES = {
    "esp32",
    "esp32s2",
    "esp32-s2",
    "esp32s3",
    "esp32-s3",
    "esp32c3",
    "esp32-c3",
    "esp32c6",
    "esp32-c6",
}


class FlashError(RuntimeError):
    pass


def _require_confirm(confirm: bool, operation: str) -> None:
    if not confirm:
        raise FlashError(
            f"{operation} is destructive and requires confirm=True. "
            "This will overwrite firmware on the device."
        )


def _reject_native_env(env: str, operation: str) -> None:
    """`native*` envs build a host executable, not firmware — there's no
    upload step. The user wants `build` (or just runs the binary directly).
    """
    if env.startswith("native"):
        raise FlashError(
            f"{operation} is not applicable for env {env!r}: native envs "
            "produce a host executable, not flashable firmware. Use `build` "
            "instead, then run the resulting binary directly."
        )


def _artifacts_for(env: str) -> list[Path]:
    build_dir = config.firmware_root() / ".pio" / "build" / env
    if not build_dir.is_dir():
        return []
    patterns = (
        "firmware*.bin",
        "firmware*.uf2",
        "firmware*.hex",
        "firmware*.zip",
        "firmware*.elf",
        "*.mt.json",
        "littlefs-*.bin",
    )
    out: list[Path] = []
    for pat in patterns:
        out.extend(sorted(build_dir.glob(pat)))
    return out


def _factory_bin_for(env: str) -> Path | None:
    build_dir = config.firmware_root() / ".pio" / "build" / env
    if not build_dir.is_dir():
        return None
    matches = sorted(build_dir.glob("firmware-*.factory.bin"))
    return matches[0] if matches else None


def _firmware_bin_for(env: str) -> Path | None:
    """Return the OTA-update firmware binary (app partition only)."""
    build_dir = config.firmware_root() / ".pio" / "build" / env
    if not build_dir.is_dir():
        return None
    # device-update.sh expects firmware-<env>-<version>.bin (not .factory.bin)
    matches = sorted(
        p
        for p in build_dir.glob("firmware-*.bin")
        if not p.name.endswith(".factory.bin")
    )
    return matches[0] if matches else None


def _userprefs_summary(active: dict[str, str]) -> dict[str, Any]:
    """Compact summary of which USERPREFS_* are baked into the build."""
    return {"count": len(active), "keys": sorted(active.keys())}


def build(
    env: str,
    with_manifest: bool = True,
    userprefs_overrides: dict[str, Any] | None = None,
    build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Run `pio run -e <env>` and return artifact paths.

    `userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
    into userPrefs.jsonc for this build only. File is restored byte-for-byte
    on exit. Use `userprefs_set()` for persistent changes.

    `build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros to set for
    this build only via `PLATFORMIO_BUILD_FLAGS`. Common useful flag:
    `{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
    prefix on every log line. Combines with the recorder so heap shows
    up at log cadence (much higher resolution than the ~60 s LocalStats
    packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
    expand to bare `-D<NAME>` (presence-only flags).
    """
    args = ["run", "-e", env]
    if with_manifest:
        args.extend(["-t", "mtjson"])
    extra_env = _build_flags_env(build_flags) if build_flags else None
    with userprefs.temporary_overrides(userprefs_overrides) as effective:
        result = pio.run(
            args,
            timeout=pio.TIMEOUT_BUILD,
            check=False,
            extra_env=extra_env,
        )
    return {
        "exit_code": result.returncode,
        "artifacts": [str(p) for p in _artifacts_for(env)],
        "stdout_tail": pio.tail_lines(result.stdout, 200),
        "stderr_tail": pio.tail_lines(result.stderr, 200),
        "duration_s": round(result.duration_s, 2),
        "userprefs": _userprefs_summary(effective),
        "build_flags": dict(build_flags) if build_flags else None,
    }


def _build_flags_env(build_flags: dict[str, Any]) -> dict[str, str]:
    """Translate `{"DEBUG_HEAP": 1, "FOO": "bar"}` → `{"PLATFORMIO_BUILD_FLAGS":
    "-DDEBUG_HEAP=1 -DFOO=bar"}`. Bool True → bare `-D<NAME>`; False/None drop
    the flag entirely. Other types stringify."""
    parts: list[str] = []
    for key, value in build_flags.items():
        if value is False or value is None:
            continue
        if value is True:
            parts.append(f"-D{key}")
        else:
            parts.append(f"-D{key}={value}")
    if not parts:
        return {}
    return {"PLATFORMIO_BUILD_FLAGS": " ".join(parts)}


def clean(env: str) -> dict[str, Any]:
    """Run `pio run -e <env> -t clean`."""
    result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
    return {
        "exit_code": result.returncode,
        "stdout_tail": pio.tail_lines(result.stdout, 200),
        "stderr_tail": pio.tail_lines(result.stderr, 200),
        "duration_s": round(result.duration_s, 2),
    }


def flash(
    env: str,
    port: str,
    confirm: bool = False,
    userprefs_overrides: dict[str, Any] | None = None,
    build_flags: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """`pio run -e <env> -t upload --upload-port <port>`. All architectures.

    `userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
    that pio performs will pick up the injected values.

    `build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
    is exported for the rebuild-before-upload, so the uploaded firmware
    actually carries the flags. Without this propagation, `pio run -t upload`
    would relink without the env var and silently drop them. Common use:
    `build_flags={"DEBUG_HEAP": 1}` for the leak-hunt path.
    """
    _require_confirm(confirm, "flash")
    _reject_native_env(env, "flash")
    connection.reject_if_tcp(port, "flash")
    extra_env = _build_flags_env(build_flags) if build_flags else None
    with userprefs.temporary_overrides(userprefs_overrides) as effective:
        result = pio.run(
            ["run", "-e", env, "-t", "upload", "--upload-port", port],
            timeout=pio.TIMEOUT_UPLOAD,
            check=False,
            extra_env=extra_env,
        )
    return {
        "exit_code": result.returncode,
        "stdout_tail": pio.tail_lines(result.stdout, 200),
        "stderr_tail": pio.tail_lines(result.stderr, 200),
        "duration_s": round(result.duration_s, 2),
        "userprefs": _userprefs_summary(effective),
        "build_flags": dict(build_flags) if build_flags else None,
    }


def _check_esp32_env(env: str) -> str:
    rec = boards.get_board(env)
    arch = rec.get("architecture")
    if arch not in ESP32_ARCHES:
        raise FlashError(
            f"Env {env!r} has architecture {arch!r}, not ESP32. "
            "Use `flash` for non-ESP32 boards."
        )
    return arch


def _run_install_script(script: Path, port: str, binary: Path) -> dict[str, Any]:
    """Invoke bin/device-install.sh or bin/device-update.sh."""
    t0 = time.monotonic()
    proc = subprocess.run(
        [str(script), "-p", port, "-f", str(binary)],
        cwd=str(config.firmware_root()),
        capture_output=True,
        text=True,
        timeout=pio.TIMEOUT_UPLOAD,
    )
    duration = time.monotonic() - t0
    return {
        "exit_code": proc.returncode,
        "stdout_tail": pio.tail_lines(proc.stdout, 200),
        "stderr_tail": pio.tail_lines(proc.stderr, 200),
        "duration_s": round(duration, 2),
    }


def erase_and_flash(
    env: str,
    port: str,
    confirm: bool = False,
    skip_build: bool = False,
    userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """ESP32-only: full erase + factory flash via bin/device-install.sh.

    `userprefs_overrides`: baked into the factory.bin via a fresh build. If
    overrides are provided we always force a rebuild (skip_build=True errors
    in that case) since a cached factory.bin would not reflect the new prefs.
    """
    _require_confirm(confirm, "erase_and_flash")
    connection.reject_if_tcp(port, "erase_and_flash")
    _check_esp32_env(env)

    if userprefs_overrides and skip_build:
        raise FlashError(
            "userprefs_overrides forces a rebuild so the factory.bin reflects "
            "the new values; skip_build=True is incompatible."
        )

    with userprefs.temporary_overrides(userprefs_overrides) as effective:
        # If overrides were provided, always build; otherwise only build if
        # no factory.bin is present.
        factory = _factory_bin_for(env)
        if factory is None or userprefs_overrides:
            if skip_build:
                raise FlashError(
                    f"No factory.bin found for env {env!r} and skip_build=True. "
                    "Run `build` first or set skip_build=False."
                )
            build_args = ["run", "-e", env, "-t", "mtjson"]
            build_result = pio.run(build_args, timeout=pio.TIMEOUT_BUILD, check=False)
            if build_result.returncode != 0:
                return {
                    "exit_code": build_result.returncode,
                    "stdout_tail": pio.tail_lines(build_result.stdout, 200),
                    "stderr_tail": pio.tail_lines(build_result.stderr, 200),
                    "duration_s": round(build_result.duration_s, 2),
                    "error": "build failed before erase_and_flash could run",
                    "userprefs": _userprefs_summary(effective),
                }
            factory = _factory_bin_for(env)
            if factory is None:
                raise FlashError(
                    f"Build succeeded but no factory.bin appeared in .pio/build/{env}/"
                )

        script = config.firmware_root() / "bin" / "device-install.sh"
        if not script.is_file():
            raise FlashError(f"device-install.sh not found at {script}")
        result = _run_install_script(script, port, factory)

    result["userprefs"] = _userprefs_summary(effective)
    return result


def update_flash(
    env: str,
    port: str,
    confirm: bool = False,
    skip_build: bool = False,
    userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """ESP32-only: OTA app-partition update via bin/device-update.sh.

    `userprefs_overrides`: baked into the firmware.bin via a fresh build. If
    overrides are provided we always force a rebuild.
    """
    _require_confirm(confirm, "update_flash")
    connection.reject_if_tcp(port, "update_flash")
    _check_esp32_env(env)

    if userprefs_overrides and skip_build:
        raise FlashError(
            "userprefs_overrides forces a rebuild so the firmware.bin reflects "
            "the new values; skip_build=True is incompatible."
        )

    with userprefs.temporary_overrides(userprefs_overrides) as effective:
        firmware = _firmware_bin_for(env)
        if firmware is None or userprefs_overrides:
            if skip_build:
                raise FlashError(
                    f"No firmware.bin found for env {env!r} and skip_build=True. "
                    "Run `build` first or set skip_build=False."
                )
            build_args = ["run", "-e", env, "-t", "mtjson"]
            build_result = pio.run(build_args, timeout=pio.TIMEOUT_BUILD, check=False)
            if build_result.returncode != 0:
                return {
                    "exit_code": build_result.returncode,
                    "stdout_tail": pio.tail_lines(build_result.stdout, 200),
                    "stderr_tail": pio.tail_lines(build_result.stderr, 200),
                    "duration_s": round(build_result.duration_s, 2),
                    "error": "build failed before update_flash could run",
                    "userprefs": _userprefs_summary(effective),
                }
            firmware = _firmware_bin_for(env)
            if firmware is None:
                raise FlashError(
                    f"Build succeeded but no firmware.bin appeared in .pio/build/{env}/"
                )

        script = config.firmware_root() / "bin" / "device-update.sh"
        if not script.is_file():
            raise FlashError(f"device-update.sh not found at {script}")
        result = _run_install_script(script, port, firmware)

    result["userprefs"] = _userprefs_summary(effective)
    return result


def _do_1200bps_touch(port: str, settle_ms: int, touch_timeout_s: float = 3.0) -> None:
    """Open port at 1200 baud and close, bounded by a worker thread.

    Both the open and the close can block on a busy CDC device — we wrap the
    whole thing in a worker so the caller returns in at most `touch_timeout_s`
    regardless. The touch is signal-only: the USB configuration change to
    1200 baud alone is enough to trip the Adafruit bootloader's reset, so a
    worker that's still blocked in the background after timeout has already
    delivered the signal.
    """
    errors: list[BaseException] = []

    def _inner() -> None:
        try:
            s = serial.Serial(port, 1200)
        except serial.SerialException as exc:
            if "No such file" in str(exc) or "could not open" in str(exc).lower():
                raise
            return  # other serial errors mid-open are expected during DFU entry
        try:
            time.sleep(settle_ms / 1000.0)
        finally:
            try:
                s.close()
            except Exception:
                pass

    def _runner() -> None:
        try:
            _inner()
        except BaseException as exc:  # re-raised on caller thread after join
            errors.append(exc)

    worker = threading.Thread(target=_runner, daemon=True)
    worker.start()
    worker.join(timeout=touch_timeout_s)
    if worker.is_alive():
        return  # signal already delivered; allow daemon worker to finish/exit
    if errors:
        raise errors[0]


# Adafruit nRF52 bootloader VID/PID (BOTH RAK4631 and most Feather nRF52 boards).
# See https://github.com/adafruit/Adafruit_nRF52_Bootloader
_NRF52_BOOTLOADER_VID = 0x239A
_NRF52_BOOTLOADER_PIDS = {
    0x0029,  # Adafruit nRF52 bootloader (generic, used by RAK4631)
    0x002A,  # Adafruit Feather Express bootloader variant
    0x4029,  # alt seen on some boards
}


def _find_nrf52_bootloader_port() -> dict[str, Any] | None:
    """Return a dict for any currently-enumerated nRF52 bootloader port, or None."""
    for d in devices.list_devices(include_unknown=True):
        vid_str = d.get("vid")
        pid_str = d.get("pid")
        if vid_str is None or pid_str is None:
            continue
        try:
            vid = int(vid_str, 16) if isinstance(vid_str, str) else int(vid_str)
            pid = int(pid_str, 16) if isinstance(pid_str, str) else int(pid_str)
        except ValueError:
            continue
        if vid == _NRF52_BOOTLOADER_VID and pid in _NRF52_BOOTLOADER_PIDS:
            return d
    return None


def touch_1200bps(
    port: str,
    settle_ms: int = 250,
    poll_timeout_s: float = 8.0,
    retries: int = 2,
) -> dict[str, Any]:
    """Open port at 1200 baud, close immediately — triggers USB CDC bootloader.

    Works for: nRF52840 (Adafruit bootloader), ESP32-S3 (native USB download
    mode), RP2040 (when built with 1200bps-reset stdio), Arduino Leonardo/Micro.

    For nRF52 specifically: after the touch, polls for the Adafruit bootloader
    VID/PID (0x239A / 0x0029) for up to `poll_timeout_s` seconds. Adafruit's
    bootloader docs note a touch sometimes needs to be repeated, so this
    retries up to `retries` times. The returned `new_port` is the bootloader
    port (distinct from the app port) — exactly what's needed for `pio run
    -t upload` to drive nrfutil.

    For non-nRF52 devices (ESP32-S3, RP2040, Arduino), falls back to
    "any-new-port appeared" detection.

    Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
    """
    connection.reject_if_tcp(port, "touch_1200bps")
    before_list = devices.list_devices(include_unknown=True)
    before_ports = {d["port"] for d in before_list}

    attempts = 0
    new_port_info: dict[str, Any] | None = None

    for attempt in range(1, retries + 1):
        attempts = attempt
        _do_1200bps_touch(port, settle_ms=settle_ms, touch_timeout_s=3.0)

        # Poll for either (a) the nRF52 bootloader VID/PID appearing, or
        # (b) a brand-new port appearing that wasn't there before.
        deadline = time.monotonic() + poll_timeout_s
        while time.monotonic() < deadline:
            time.sleep(0.2)

            bootloader = _find_nrf52_bootloader_port()
            if bootloader is not None:
                new_port_info = bootloader
                break

            current = devices.list_devices(include_unknown=True)
            current_paths = {d["port"] for d in current}
            added = current_paths - before_ports
            if added:
                added_record = next((d for d in current if d["port"] in added), None)
                if added_record:
                    new_port_info = added_record
                    break

        if new_port_info is not None:
            break
        # No bootloader appeared; try touching again (Adafruit recommends
        # sometimes requiring two touches for reliability).

    if new_port_info is not None:
        return {
            "ok": True,
            "former_port": port,
            "new_port": new_port_info["port"],
            "new_port_vid_pid": (
                new_port_info.get("vid"),
                new_port_info.get("pid"),
            ),
            "attempts": attempts,
        }

    return {
        "ok": False,
        "former_port": port,
        "new_port": None,
        "new_port_vid_pid": (None, None),
        "attempts": attempts,
    }
