"""Long-running serial monitor sessions via `pio device monitor`.

Why pio instead of raw pyserial: pio applies the board's monitor_filters —
`esp32_exception_decoder` symbolicates crash stacks, `time` adds timestamps,
etc. Raw pyserial would give us bytes; pio gives us developer-grade logs.

Each session runs `pio device monitor` in a subprocess, with a daemon reader
thread draining stdout into a bounded ring buffer. Callers pull lines via
`serial_read` using a cursor that survives across calls.
"""

from __future__ import annotations

import collections
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any

from . import boards, config

_BUFFER_MAX_LINES = 10_000
_POLL_NEW_PORT_TIMEOUT_S = 3.0


@dataclass
class SerialSession:
    id: str
    port: str
    baud: int
    filters: list[str]
    env: str | None
    proc: subprocess.Popen
    buffer: collections.deque = field(
        default_factory=lambda: collections.deque(maxlen=_BUFFER_MAX_LINES)
    )
    # Total lines seen (not bounded by buffer maxlen). `dropped = total - len(buffer)`
    # if the reader has advanced past buffer head.
    total_lines: int = 0
    started_at: float = field(default_factory=time.time)
    stopped_at: float | None = None
    lock: threading.Lock = field(default_factory=threading.Lock)
    _thread: threading.Thread | None = None


def _drain(session: SerialSession) -> None:
    """Reader thread: line-by-line pull stdout into buffer.

    Each line is also published to the `meshtastic.serial.line` pubsub
    topic so the persistent recorder can capture it without holding its
    own port. This is the text-mode tap path: when no SerialInterface is
    open, the firmware emits full formatted lines (level + clock + uptime
    + thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
    fan them out to whoever is listening. Pubsub is best-effort —
    publish failures must never block the reader.
    """
    # Lazy import: pubsub isn't required just to import this module
    # (e.g., during static analysis), and we want a clean test surface.
    try:
        from pubsub import pub  # type: ignore[import-untyped]
    except Exception:  # pragma: no cover - defensive
        pub = None

    assert session.proc.stdout is not None
    try:
        for line in session.proc.stdout:
            line_stripped = line.rstrip("\r\n")
            with session.lock:
                session.buffer.append(line_stripped)
                session.total_lines += 1
            if pub is not None:
                try:
                    pub.sendMessage(
                        "meshtastic.serial.line",
                        line=line_stripped,
                        port=session.port,
                    )
                except Exception:
                    # A subscriber raising must not break the reader.
                    pass
    except Exception:  # pragma: no cover - defensive
        pass
    finally:
        session.stopped_at = time.time()


def open_session(
    port: str,
    baud: int = 115200,
    env: str | None = None,
    filters: list[str] | None = None,
) -> SerialSession:
    """Spawn `pio device monitor` and return a SerialSession.

    If `env` is supplied, pio resolves baud and filters from platformio.ini.
    Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
    """
    # Lazy import to avoid circular: registry imports serial_session.
    from . import connection

    connection.reject_if_tcp(port, "serial_open")
    args = ["device", "monitor", "--port", port, "--no-reconnect"]
    effective_filters: list[str]
    effective_baud: int = baud
    if env is not None:
        args.extend(["-e", env])
        raw_config: dict[str, Any] = {}
        try:
            raw = boards.get_board(env).get("raw_config")
            if isinstance(raw, dict):
                raw_config = raw
        except Exception:
            raw_config = {}

        monitor_speed = raw_config.get("monitor_speed")
        has_board_speed = False
        if monitor_speed is not None:
            try:
                effective_baud = int(str(monitor_speed).strip())
                has_board_speed = True
            except (TypeError, ValueError):
                pass

        monitor_filters_raw = raw_config.get("monitor_filters")
        parsed_board_filters: list[str] = []
        if isinstance(monitor_filters_raw, str):
            for token in monitor_filters_raw.replace("\n", ",").split(","):
                item = token.strip()
                if item:
                    parsed_board_filters.append(item)
        elif isinstance(monitor_filters_raw, list):
            parsed_board_filters = [
                str(item).strip() for item in monitor_filters_raw if str(item).strip()
            ]

        has_board_filters = len(parsed_board_filters) > 0
        effective_filters = (
            parsed_board_filters if has_board_filters else (filters or [])
        )

        if not has_board_speed:
            args.extend(["--baud", str(effective_baud)])
        if not has_board_filters:
            for f in effective_filters:
                args.extend(["--filter", f])
    else:
        args.extend(["--baud", str(baud)])
        effective_filters = filters or ["direct"]
        for f in effective_filters:
            args.extend(["--filter", f])

    binary = str(config.pio_bin())
    work_dir = str(config.firmware_root())

    proc = subprocess.Popen(
        [binary, *args],
        cwd=work_dir,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,  # line-buffered
    )

    session = SerialSession(
        id=uuid.uuid4().hex,
        port=port,
        baud=effective_baud,
        filters=effective_filters,
        env=env,
        proc=proc,
    )
    t = threading.Thread(target=_drain, args=(session,), daemon=True)
    t.start()
    session._thread = t
    return session


def read_session(
    session: SerialSession, max_lines: int = 200, since_cursor: int | None = None
) -> dict[str, Any]:
    """Snapshot recent lines from the buffer.

    Cursor semantics: the global cursor is `total_lines` at read time. Pass
    `since_cursor` from a previous response's `new_cursor` to page forward.
    `since_cursor=0` reads everything still in the ring buffer.
    """
    with session.lock:
        total = session.total_lines
        buf_len = len(session.buffer)
        head_cursor = total - buf_len  # cursor value at buffer[0]
        current_buffer = list(session.buffer)

    if since_cursor is None:
        since_cursor = head_cursor

    # Clamp: never read what's aged out of the buffer.
    effective_start = max(since_cursor, head_cursor)
    # Number of lines skipped because they aged out between reads.
    dropped = max(0, head_cursor - since_cursor) if since_cursor < head_cursor else 0

    start_idx = effective_start - head_cursor
    end_idx = min(start_idx + max_lines, buf_len)
    lines = current_buffer[start_idx:end_idx]
    new_cursor = effective_start + len(lines)

    eof = session.proc.poll() is not None
    return {
        "lines": lines,
        "new_cursor": new_cursor,
        "eof": eof,
        "dropped": dropped,
    }


def close_session(session: SerialSession) -> bool:
    """Terminate the subprocess and join the reader thread."""
    proc = session.proc
    if proc.poll() is None:
        try:
            proc.terminate()
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait(timeout=3)
    if session._thread is not None:
        session._thread.join(timeout=3)
    session.stopped_at = session.stopped_at or time.time()
    return True


def session_summary(session: SerialSession) -> dict[str, Any]:
    with session.lock:
        line_count = session.total_lines
    return {
        "session_id": session.id,
        "port": session.port,
        "baud": session.baud,
        "filters": session.filters,
        "env": session.env,
        "started_at": session.started_at,
        "stopped_at": session.stopped_at,
        "line_count": line_count,
        "eof": session.proc.poll() is not None,
    }
