#!/usr/bin/env python3 """Curses TUI for the filament dryer Arduino.""" from __future__ import annotations import curses import re import sys import threading import time from collections import deque from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from capture_csv import ( CsvSession, decode_line, ) PRESETS: list[tuple[str, float]] = [ ("PLA", 55), ("PETG", 65), ("ABS", 65), ("ASA", 65), ("HIPS", 65), ("Nylon", 75), ("PC", 75), ("PVA", 45), ("TPU/TPE", 55), ("PP", 55), ("Idle", 0), ] STATUS_RE = re.compile( r"target=(?P\S+)\s+" r"cutoff=(?P\S+)\s+" r"avg=(?P[\d.]+)C\s+" r"min=(?P[\d.]+)C\s+" r"max=(?P[\d.]+)C\s+" r"spread=(?P[\d.]+)C\s+" r"heatlim=(?P\d+)%\s+" r"heater=(?P[\d.]+)%\s+" r"htop=(?P\S+)\s+" r"hblk=(?P\S+)\s+" r"ssr=(?Pon|off)\s+" r"fan=(?P\d+/255\([^)]+\)(?:\([^)]+\))?(?:\s+TEST)?)\s+" r"cutoff=(?P\S+)\s+" r"failsafe=(?P\S+)\s+" r"mode=(?P.+?)\s+sensors=\[(?P.*)\]" ) SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR") AUTOTUNE_MODE_RE = re.compile( r"autotune/(?P[\w-]+) (?P\d+)s (?P\d+/\d+)cyc pre>=(?P
\d+)C"
)

FANCHARS_MODE_RE = re.compile(
    r"fanchars/(?P[\w-]+) (?P\d+)s run (?P[\w]+)/(?P\d+) "
    r"fan=(?P\d+) heat=(?P\d+)%"
)

FANCHARS_PHASE_HELP: dict[str, str] = {
    "precool": "Cooling chamber to 40 C avg before first fan test (fan at 100% now)",
    "cool": "Cooling to 40 C avg before next fan test (fan at 100% now)",
    "heat": "Heating to 60 C max corner at test fan speed",
    "hold": "Holding at max — measuring temperature spread",
    "refine": "Refine run — midpoint PWM between two best spreads",
}


def fan_pct_from_pwm(pwm: int) -> int:
    return (pwm * 100) // 255


def format_fan_display(fan_raw: str) -> str:
    match = re.match(r"(\d+)/255\((\d+)%\)(.*)$", fan_raw.strip())
    if not match:
        return fan_raw
    suffix = match.group(3).strip()
    pct = match.group(2)
    if suffix:
        return f"{pct}% {suffix}"
    return f"{pct}%"


def format_mode_line(mode: str, avg: str = "") -> tuple[str, str]:
    """Return (mode summary, activity detail) for the dashboard."""
    match = AUTOTUNE_MODE_RE.match(mode)
    if match:
        d = match.groupdict()
        summary = (
            f"Autotune {d['phase']}: {d['elapsed']}s, "
            f"{d['cycles']} cycles, preheat avg >= {d['pre']} C"
        )
        return summary, "Relay tuning heat PI — heater bang-bang around setpoint"

    match = FANCHARS_MODE_RE.match(mode)
    if match:
        d = match.groupdict()
        phase = d["phase"]
        test_pct = fan_pct_from_pwm(int(d["testfan"]))
        run = d["run"]
        runs = d["runs"]
        if run == "pre":
            run_text = f"preparing (before 1/{runs})"
        elif run.startswith("n"):
            run_text = f"before {run[1:]}/{runs} ({test_pct}% fan next)"
        elif run == "refine":
            run_text = f"refine ({test_pct}% fan)"
        else:
            run_text = f"{run}/{runs} ({test_pct}% fan)"
        summary = f"Fan chars {phase}: {d['elapsed']}s — {run_text}"
        detail = FANCHARS_PHASE_HELP.get(phase, "")
        if phase in ("precool", "cool") and avg not in ("—", ""):
            try:
                detail += f" — avg {avg} C"
            except ValueError:
                pass
        return summary, detail

    if mode in ("manual", "regulating"):
        return f"Mode: {mode}", "Normal temperature control"
    return f"Mode: {mode}", ""


@dataclass
class DryerState:
    target: str = "—"
    cutoff: str = "—"
    avg: str = "—"
    min_temp: str = "—"
    max_temp: str = "—"
    spread: str = "—"
    heatlim: str = "—"
    heater: str = "—"
    htop: str = "—"
    hblk: str = "—"
    ssr: str = "—"
    fan: str = "—"
    fan_note: str = ""
    cutoff_active: str = "no"
    failsafe: str = "no"
    mode: str = "—"
    activity: str = ""
    sensors: list[tuple[str, str, str]] = field(default_factory=list)
    messages: deque[str] = field(default_factory=lambda: deque(maxlen=24))
    csv_logging: bool = False
    csv_path: Path | None = None
    port: str = ""
    connected: bool = False


def parse_status(line: str) -> dict | None:
    match = STATUS_RE.search(line)
    if not match:
        return None
    data = match.groupdict()
    sensors: list[tuple[str, str, str]] = []
    for part in data["sensors"].split(", "):
        part = part.strip()
        if not part:
            continue
        m = SENSOR_RE.match(part)
        if not m:
            continue
        if m.group(4):
            sensors.append((m.group(4), "ERR", ""))
        else:
            sensors.append((m.group(1), m.group(2), m.group(3)))
    data["sensor_list"] = sensors
    return data


def apply_status(state: DryerState, data: dict) -> None:
    target = data["target"]
    if target.startswith("idle"):
        state.target = "idle"
    elif target.endswith("C"):
        state.target = target[:-1]
    else:
        state.target = target
    cutoff = data["cutoff_temp"]
    if cutoff in ("n/aC", "n/a"):
        state.cutoff = "n/a"
    elif cutoff.endswith("C"):
        state.cutoff = cutoff[:-1]
    else:
        state.cutoff = cutoff
    state.avg = data["avg"]
    state.min_temp = data["min"]
    state.max_temp = data["max"]
    state.spread = data["spread"]
    state.heatlim = data["heatlim"]
    state.heater = data["heater"]
    state.htop = data["htop"].removesuffix("C") if data["htop"].endswith("C") else data["htop"]
    state.hblk = data["hblk"]
    state.ssr = data["ssr"]
    fan_raw = data["fan"]
    state.fan = format_fan_display(fan_raw)
    state.fan_note = ""
    if "(off)" in fan_raw or "(cooldown)" in fan_raw:
        state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
    elif "(manual)" in fan_raw or "(stir)" in fan_raw:
        state.fan_note = fan_raw[fan_raw.find("(") :] if "(" in fan_raw else ""
    elif "(fanchars-" in fan_raw:
        state.fan_note = fan_raw[fan_raw.find("(fanchars-") :]
    state.cutoff_active = data["cutoff_active"]
    state.failsafe = data["failsafe"]
    state.mode = data["mode"]
    summary, activity = format_mode_line(data["mode"], data["avg"])
    state.mode = summary
    state.activity = activity
    state.sensors = data["sensor_list"]


class SerialWorker:
    def __init__(self, ser, state: DryerState, lock: threading.Lock):
        self.ser = ser
        self.state = state
        self.lock = lock
        self.stop = threading.Event()
        self._csv: CsvSession | None = None
        self._thread: threading.Thread | None = None

    def start(self) -> None:
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        with self.lock:
            self.state.connected = True
            self.state.port = getattr(self.ser, "port", "") or getattr(self.ser, "name", "")
            self.state.messages.append("Connected")

    def close(self) -> None:
        self.stop.set()
        if self._thread is not None:
            self._thread.join(timeout=1.5)
        if self._csv is not None:
            self._csv.close()
            self._csv = None

    def send(self, command: str) -> None:
        if self.ser is None:
            return
        self.ser.write((command.strip() + "\n").encode("utf-8"))
        self.ser.flush()

    def set_csv_logging(self, enabled: bool, log_dir: Path) -> None:
        with self.lock:
            if enabled and not self.state.csv_logging:
                log_dir.mkdir(parents=True, exist_ok=True)
                path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
                self._csv = CsvSession(path)
                self.state.csv_path = path
                self.state.csv_logging = True
                self.state.messages.append(f"CSV -> {path.name} (on status)")
                self.send("log on")
            elif not enabled and self.state.csv_logging:
                self.send("log off")
                self.state.csv_logging = False
                self.state.csv_path = None
                if self._csv is not None:
                    rows = self._csv.row_count
                    self._csv.close()
                    self._csv = None
                    self.state.messages.append(f"CSV logging off ({rows} rows)")
                else:
                    self.state.messages.append("CSV logging off")

    def _note(self, line: str) -> None:
        with self.lock:
            self.state.messages.append(line)

    def _run(self) -> None:
        assert self.ser is not None
        while not self.stop.is_set():
            try:
                raw = self.ser.readline()
            except Exception as exc:
                with self.lock:
                    self.state.messages.append(f"Serial error: {exc}")
                break
            if not raw:
                continue
            line = decode_line(raw)
            if not line:
                continue

            if line.startswith("fanchars:"):
                self._note(line)
                continue

            if line.startswith("csv,") or line.startswith("csv_hdr,"):
                continue

            parsed = parse_status(line)
            if parsed:
                with self.lock:
                    apply_status(self.state, parsed)
                if self._csv is not None:
                    self._csv.write_status(parsed)
                continue

            if line.startswith("target="):
                self._note("WARN: could not parse status line")
                continue

            if line.startswith("OK") or line.startswith("ERR") or line.startswith("WARN"):
                self._note(line)


def _draw_box(win, y: int, x: int, h: int, w: int, title: str) -> None:
    if h < 2 or w < 4:
        return
    try:
        win.addstr(y, x, "+" + "-" * (w - 2) + "+")
        win.addstr(y, x + 2, f" {title} "[: max(0, w - 4)])
        for row in range(1, h - 1):
            win.addstr(y + row, x, "|" + " " * (w - 2) + "|")
        win.addstr(y + h - 1, x, "+" + "-" * (w - 2) + "+")
    except curses.error:
        pass


def _safe_addstr(win, y: int, x: int, text: str, attr: int = 0) -> None:
    height, width = win.getmaxyx()
    if y < 0 or y >= height or x >= width:
        return
    win.addnstr(y, x, text, max(0, width - x - 1), attr)


def _prompt(stdscr, label: str) -> str | None:
    stdscr.nodelay(False)
    stdscr.timeout(-1)
    curses.curs_set(1)
    height, width = stdscr.getmaxyx()
    prompt = f" {label}: "
    row = height - 1
    col = len(prompt)
    buf: list[str] = []

    _safe_addstr(stdscr, row, 0, " " * max(0, width - 1))
    _safe_addstr(stdscr, row, 0, prompt)
    stdscr.move(row, col)
    stdscr.refresh()

    try:
        while True:
            ch = stdscr.getch()
            if ch in (10, 13, curses.KEY_ENTER):
                break
            if ch in (27,):  # Esc
                return None
            if ch in (curses.KEY_BACKSPACE, 127, 8):
                if buf:
                    buf.pop()
            elif ch == curses.KEY_DC:  # Delete — ignore
                pass
            elif 32 <= ch <= 126 and len(buf) < 24:
                buf.append(chr(ch))
            _safe_addstr(stdscr, row, col, (" " * 24))
            _safe_addstr(stdscr, row, col, "".join(buf))
            stdscr.move(row, col + len(buf))
            stdscr.refresh()
    finally:
        curses.curs_set(0)
        stdscr.nodelay(True)
        stdscr.timeout(200)

    text = "".join(buf).strip()
    return text


def _preset_menu(stdscr, worker: SerialWorker) -> None:
    height, width = stdscr.getmaxyx()
    menu_h = min(len(PRESETS) + 2, height - 4)
    menu_w = 28
    y0 = (height - menu_h) // 2
    x0 = (width - menu_w) // 2
    selected = 0

    while True:
        stdscr.erase()
        _draw_box(stdscr, y0, x0, menu_h, menu_w, "Presets")
        for i, (name, temp) in enumerate(PRESETS):
            label = f" {name:<10} {temp:>5.0f} °C"
            attr = curses.A_REVERSE if i == selected else 0
            _safe_addstr(stdscr, y0 + 1 + i, x0 + 1, label.ljust(menu_w - 2), attr)
        _safe_addstr(stdscr, y0 + menu_h - 1, x0 + 2, "Enter select  Esc cancel")
        stdscr.refresh()
        key = stdscr.getch()
        if key in (27, ord("q")):
            return
        if key in (curses.KEY_UP, ord("k")):
            selected = (selected - 1) % len(PRESETS)
        elif key in (curses.KEY_DOWN, ord("j")):
            selected = (selected + 1) % len(PRESETS)
        elif key in (10, 13, curses.KEY_ENTER):
            name, temp = PRESETS[selected]
            worker.send(f"target {temp:g}")
            worker._note(f"Preset {name} -> {temp:g} °C")
            return


def _draw_dashboard(stdscr, state: DryerState) -> None:
    stdscr.erase()
    height, width = stdscr.getmaxyx()
    if height < 20 or width < 60:
        _safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x20).")
        stdscr.refresh()
        return

    title = f" Filament Dryer — {state.port} "
    _safe_addstr(stdscr, 0, 1, title, curses.A_BOLD)

    row = 2
    _safe_addstr(stdscr, row, 2, f"Target: {state.target:>6} C  Limit: {state.cutoff} C", curses.A_BOLD)
    cutoff_attr = curses.A_BOLD | curses.color_pair(1) if state.cutoff_active == "YES" else 0
    _safe_addstr(stdscr, row, 36, f"Trip: {state.cutoff_active}", cutoff_attr)

    row += 1
    mode_text = state.mode
    _safe_addstr(stdscr, row, 2, f"{mode_text[: max(0, width - 18)]}  FS: {state.failsafe}")

    row += 1
    if state.activity:
        _safe_addstr(stdscr, row, 2, state.activity[: max(0, width - 4)], curses.A_DIM)
        row += 1
    _safe_addstr(stdscr, row, 2, f"Avg: {state.avg} C   Min: {state.min_temp} C   Max: {state.max_temp} C   Spread: {state.spread} C")

    row += 1
    fan_text = state.fan
    if state.fan_note and "(fanchars-" not in state.fan_note:
        fan_text = f"{state.fan} {state.fan_note}"
    _safe_addstr(
        stdscr,
        row,
        2,
        f"Heater: {state.heater} %  SSR: {state.ssr}  Fan: {fan_text}  Limit: {state.heatlim} %",
    )

    row += 1
    _safe_addstr(stdscr, row, 2, f"Heat stop: {state.htop} C   Block: {state.hblk}")

    row += 2
    _draw_box(stdscr, row, 1, 5, width - 2, "Sensors")
    sensor_row = row + 1
    col = 3
    if state.sensors:
        for ch, temp, hum in state.sensors:
            if temp == "ERR":
                text = f"ch{ch}: ERR"
            else:
                text = f"ch{ch}: {temp} °C  {hum} %"
            _safe_addstr(stdscr, sensor_row, col, text.ljust(22))
            col += 24
            if col + 22 >= width - 2:
                sensor_row += 1
                col = 3
    else:
        _safe_addstr(stdscr, sensor_row, 3, "Waiting for readings…")

    row += 5
    help_y = height - 2
    log_h = help_y - row - 1
    if log_h < 4:
        _safe_addstr(stdscr, row, 2, "Terminal too small for message panel.")
        stdscr.refresh()
        return

    _draw_box(stdscr, row, 1, log_h, width - 2, "Messages")
    csv_line = "on" if state.csv_logging else "off"
    if state.csv_path:
        csv_line += f" ({state.csv_path.name})"
    _safe_addstr(stdscr, row + 1, 3, f"CSV: {csv_line}")
    msg_row = row + 2
    for msg in list(state.messages)[-(log_h - 3) :]:
        _safe_addstr(stdscr, msg_row, 3, msg[: width - 6])
        msg_row += 1

    help_y = height - 2
    _safe_addstr(
        stdscr,
        help_y,
        1,
        "0 idle | t target | p presets | f fan | l log | a autotune | c fanchars | : cmd | q quit",
        curses.A_DIM,
    )
    stdscr.refresh()


def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int:
    curses.curs_set(0)
    curses.start_color()
    curses.use_default_colors()
    curses.init_pair(1, curses.COLOR_RED, -1)
    stdscr.nodelay(True)
    stdscr.timeout(200)
    stdscr.clear()

    state = DryerState()
    lock = threading.Lock()
    worker = SerialWorker(ser, state, lock)
    worker.start()
    worker.send("status")
    time.sleep(0.4)
    worker.send("status")
    if auto_log_on:
        worker.set_csv_logging(True, log_dir)

    try:
        while True:
            with lock:
                snapshot = DryerState(
                    target=state.target,
                    cutoff=state.cutoff,
                    avg=state.avg,
                    min_temp=state.min_temp,
                    max_temp=state.max_temp,
                    spread=state.spread,
                    heatlim=state.heatlim,
                    heater=state.heater,
                    htop=state.htop,
                    hblk=state.hblk,
                    ssr=state.ssr,
                    fan=state.fan,
                    fan_note=state.fan_note,
                    cutoff_active=state.cutoff_active,
                    failsafe=state.failsafe,
                    mode=state.mode,
                    activity=state.activity,
                    sensors=list(state.sensors),
                    messages=deque(state.messages, maxlen=24),
                    csv_logging=state.csv_logging,
                    csv_path=state.csv_path,
                    port=state.port,
                    connected=state.connected,
                )
            _draw_dashboard(stdscr, snapshot)

            key = stdscr.getch()
            if key == -1:
                continue
            if key in (ord("q"), ord("Q"), 27):
                break
            if key == ord("0"):
                worker.send("target 0")
            elif key == ord("t"):
                value = _prompt(stdscr, "Target °C (0 = idle)")
                if value is not None and value != "":
                    worker.send(f"target {value}")
            elif key == ord("p"):
                stdscr.nodelay(False)
                stdscr.timeout(-1)
                _preset_menu(stdscr, worker)
                stdscr.nodelay(True)
                stdscr.timeout(200)
            elif key == ord("f"):
                worker.send("fan on")
            elif key == ord("F"):
                worker.send("fan off")
            elif key == ord("l"):
                with lock:
                    enable = not state.csv_logging
                worker.set_csv_logging(enable, log_dir)
            elif key == ord("a"):
                value = _prompt(stdscr, "Autotune °C (Enter = 40)")
                if value is not None:
                    cmd = "autotune" if value == "" else f"autotune {value}"
                    worker.send(cmd)
            elif key == ord("c"):
                worker.send("fanchars")
                worker._note("Started fanchars — 30/100/60/80% then refine if needed")
            elif key == ord(":"):
                value = _prompt(stdscr, "Command")
                if value is not None and value != "":
                    worker.send(value)
            elif key == ord("s"):
                worker.send("status")
    finally:
        worker.close()

    return 0


def run_tui(port: str | None, baud: int, log_dir: Path, auto_log_on: bool = True) -> int:
    if not sys.stdin.isatty() or not sys.stdout.isatty():
        print("TUI requires an interactive terminal.", file=sys.stderr)
        print("Use: ssh -t user@host 'python3 scripts/capture_csv.py tui'", file=sys.stderr)
        print("Headless logging: python3 scripts/capture_csv.py log", file=sys.stderr)
        return 1

    from capture_csv import import_serial, open_serial, resolve_port

    resolved = resolve_port(port)
    import_serial()
    try:
        ser = open_serial(resolved, baud)
    except Exception as exc:
        print(f"Cannot open serial port {resolved}: {exc}", file=sys.stderr)
        return 1

    try:
        return curses.wrapper(
            lambda stdscr: _curses_main(stdscr, ser, log_dir, auto_log_on)
        )
    except curses.error as exc:
        print(f"TUI failed: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1
    finally:
        if ser.is_open:
            ser.close()


if __name__ == "__main__":
    raise SystemExit(run_tui(None, 115200, Path("logs"), True))