#!/usr/bin/env python3
"""Seeed Dual GbE J19 pin probe (Raspberry Pi only).

Walk J19 positions with ↑/↓. Selected GPIO square-waves so a logic probe can
see which flex pad is live. Power/GND rows still selectable (no drive).

Feedback is one updating line — selected pin + ON/OFF. No worksheet / table.

  cd ~/EtherGold/sync
  sudo ./run_j19_io_mapper.sh
  sudo python3 j19_io_mapper.py --period-ms 250 --start 1

If needed: dtparam=i2c_arm=off / dtparam=spi=off then reboot (for GPIO2/3/SPI).
3.3 V only. See HS_GPIO_TAP.md / POG422_PI_HS_FPC_MAPPING.html.
"""

from __future__ import annotations

import argparse
import os
import select
import shutil
import signal
import subprocess
import sys
import termios
import time
import tty
from dataclasses import dataclass
from typing import Optional


DEFAULT_PERIOD_MS = 250

# Physical FPC/J19 pin order — verified 2026-08-11 (logic-probe + j19_io_mapper).
# Seeed Dual GbE schematic sheet 13 lists 3V3/GND then GPIO3…11 in reverse of this
# pad order. "I/O Line" during probe was the old schematic row (8..3 = BCM bank).
# bcm=None = power/return (do not drive).
J19_PHYSICAL_LINES: list[tuple[Optional[int], str]] = [
    (11, "pin1 BCM11/GPIO11  M-S_OUT"),
    (9, "pin2 BCM9/GPIO9    M-S_IN"),
    (10, "pin3 BCM10/GPIO10 OIS_OUT"),
    (8, "pin4 BCM8/GPIO8    OIS_IN"),
    (2, "pin5 BCM2/GPIO2    LED1 EtherGold (active-low, 470R to 3V3)"),
    (3, "pin6 BCM3/GPIO3    LED2 DyzineFrame (active-low, 470R to 3V3)"),
    (None, "pin7 nc (no flex GND)"),
    (None, "pin8 nc (no 3.3V over flex)"),
]
# Alias kept so older references / docs that said "schematic lines" still resolve.
J19_SCHEMATIC_LINES = J19_PHYSICAL_LINES


@dataclass
class Line:
    index: int  # 1-based connector position
    bcm: Optional[int]
    label: str


class MapperError(RuntimeError):
    pass


def have_pinctrl() -> bool:
    return shutil.which("pinctrl") is not None


def pinctrl(*args: str) -> subprocess.CompletedProcess[str]:
    if not have_pinctrl():
        raise MapperError("pinctrl not found — run on Raspberry Pi OS Bookworm+")
    return subprocess.run(
        ["pinctrl", *args],
        check=False,
        text=True,
        capture_output=True,
    )


def pinctrl_get(bcm: int) -> str:
    cp = pinctrl("get", str(bcm))
    return (cp.stdout or "").strip() or (cp.stderr or "").strip() or "(no output)"


def set_input_pull_up(bcm: int) -> None:
    cp = pinctrl("set", str(bcm), "ip", "pu")
    if cp.returncode != 0:
        raise MapperError(
            f"pinctrl set {bcm} ip pu failed (rc={cp.returncode}): "
            f"{(cp.stderr or cp.stdout or '').strip()}"
        )


def set_output_level(bcm: int, high: bool) -> None:
    level = "dh" if high else "dl"
    cp = pinctrl("set", str(bcm), "op", level)
    if cp.returncode != 0:
        raise MapperError(
            f"pinctrl set {bcm} op {level} failed (rc={cp.returncode}): "
            f"{(cp.stderr or cp.stdout or '').strip()}"
        )


def all_bcms(lines: list[Line]) -> list[int]:
    out: list[int] = []
    for ln in lines:
        if ln.bcm is not None and ln.bcm not in out:
            out.append(ln.bcm)
    return out


def warn_alt_functions(bcms: list[int]) -> None:
    bad: list[str] = []
    for bcm in bcms:
        text = pinctrl_get(bcm).lower()
        for keyword in ("i2c", "spi", "uart", "pwm", "sda", "scl"):
            if keyword in text:
                bad.append(f"  BCM {bcm}: {pinctrl_get(bcm)}")
                break
    if not bad:
        return
    print("WARNING: alt functions still claimed (probe may fight HW):", flush=True)
    print("\n".join(bad), flush=True)
    print(
        "Disable I2C/SPI if needed (config.txt: dtparam=i2c_arm=off  dtparam=spi=off)\n"
        "Then reboot. Continuing anyway.\n",
        flush=True,
    )


def release_all(bcms: list[int]) -> None:
    for bcm in bcms:
        try:
            set_input_pull_up(bcm)
        except MapperError as exc:
            print(f"cleanup warn BCM {bcm}: {exc}", file=sys.stderr)


def build_lines_j19() -> list[Line]:
    return [
        Line(index=i + 1, bcm=bcm, label=label)
        for i, (bcm, label) in enumerate(J19_SCHEMATIC_LINES)
    ]


def build_lines_from_bcms(bcms: list[int]) -> list[Line]:
    if not bcms:
        raise SystemExit("FATAL: --lines produced an empty list")
    return [
        Line(index=i + 1, bcm=bcm, label=f"BCM {bcm} / GPIO{bcm}")
        for i, bcm in enumerate(bcms)
    ]


def parse_lines_arg(raw: str) -> list[int]:
    parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
    out: list[int] = []
    for p in parts:
        try:
            n = int(p, 0)
        except ValueError as exc:
            raise SystemExit(f"FATAL: bad BCM in --lines: {p!r}") from exc
        if n < 0 or n > 57:
            raise SystemExit(f"FATAL: BCM {n} out of expected range")
        out.append(n)
    return out


def _drain_stdin_chars(max_n: int = 8) -> str:
    """Pull available bytes after first ESC (SSH often delivers CSI in a burst)."""
    buf = ""
    for _ in range(max_n):
        r, _, _ = select.select([sys.stdin], [], [], 0.02)
        if not r:
            break
        buf += sys.stdin.read(1)
    return buf


def read_key(timeout_s: float) -> Optional[str]:
    """Return 'up', 'down', 'quit', or None on timeout."""
    r, _, _ = select.select([sys.stdin], [], [], timeout_s)
    if not r:
        return None
    ch = sys.stdin.read(1)
    if ch in ("\x03", "q", "Q"):
        return "quit"
    if ch == "\x1b":
        rest = _drain_stdin_chars()
        # CSI: [A / [B   application-cursor: OA / OB
        if rest in ("[A", "OA") or rest.endswith("A"):
            return "up"
        if rest in ("[B", "OB") or rest.endswith("B"):
            return "down"
        return None
    if ch in ("k", "K", "w", "W"):
        return "up"
    if ch in ("j", "J", "s", "S"):
        return "down"
    return None


def status_line(
    sel: Line,
    selected: int,
    total: int,
    level_high: Optional[bool],
    period_ms: int,
) -> str:
    """Single-line status for \r rewrite (no newlines)."""
    if sel.bcm is None:
        state = "----"
        drive = "no GPIO drive (power/GND)"
    else:
        state = "ON " if level_high else "OFF"
        drive = f"BCM {sel.bcm}  {period_ms}ms square"
    # fixed-ish width fields so the eye tracks ON/OFF
    return (
        f"  [{selected + 1}/{total}]  {sel.label}  |  "
        f"DRIVE {state}  |  {drive}  |  ↑↓ pin  q quit"
    )


def paint_status(line: str) -> None:
    # Same line only: CR + clear-to-EOL + text
    sys.stdout.write("\r\033[K" + line)
    sys.stdout.flush()


def run(lines: list[Line], period_ms: int, start_index: Optional[int] = None) -> int:
    if not sys.stdin.isatty():
        print("FATAL: need an interactive TTY (run from a terminal)", file=sys.stderr)
        return 2
    if os.geteuid() != 0 and have_pinctrl():
        print("Note: not root — if pinctrl fails, re-run with sudo.\n")

    bcms = all_bcms(lines)
    if not bcms:
        print("FATAL: no GPIO BCMs in line list", file=sys.stderr)
        return 2

    warn_alt_functions(bcms)
    release_all(bcms)

    selected = 0
    if start_index is not None:
        if start_index < 1 or start_index > len(lines):
            print(
                f"FATAL: --start must be 1..{len(lines)}, got {start_index}",
                file=sys.stderr,
            )
            return 2
        selected = start_index - 1

    level_high = False
    period_s = max(0.02, period_ms / 1000.0)
    next_toggle = time.monotonic()
    stop = False

    def _sig(_s: int, _f: object) -> None:
        nonlocal stop
        stop = True

    signal.signal(signal.SIGINT, _sig)
    signal.signal(signal.SIGTERM, _sig)

    # One-shot banner — then only the status line rewrites.
    print(
        "J19 probe  |  ↑/↓ or k/j or w/s change pin  |  selected line toggles ON/OFF\n"
        "Logic probe on flex/J19 pad: square wave = that pad is this pin.\n"
        "Power pins never drive.  Ctrl-C or q quit.\n",
        flush=True,
    )

    old = termios.tcgetattr(sys.stdin.fileno())
    try:
        tty.setcbreak(sys.stdin.fileno())
        sel = lines[selected]
        if sel.bcm is not None:
            set_output_level(sel.bcm, level_high)
        paint_status(
            status_line(
                sel,
                selected,
                len(lines),
                level_high if sel.bcm is not None else None,
                period_ms,
            )
        )

        while not stop:
            key = read_key(0.02)
            if key == "quit":
                break
            if key in ("up", "down"):
                prev = lines[selected]
                if prev.bcm is not None:
                    set_input_pull_up(prev.bcm)
                if key == "up":
                    selected = (selected - 1) % len(lines)
                else:
                    selected = (selected + 1) % len(lines)
                level_high = False
                next_toggle = time.monotonic()
                sel = lines[selected]
                if sel.bcm is not None:
                    set_output_level(sel.bcm, level_high)
                paint_status(
                    status_line(
                        sel,
                        selected,
                        len(lines),
                        level_high if sel.bcm is not None else None,
                        period_ms,
                    )
                )
                continue

            now = time.monotonic()
            sel = lines[selected]
            if sel.bcm is not None and now >= next_toggle:
                level_high = not level_high
                set_output_level(sel.bcm, level_high)
                next_toggle = now + period_s
                paint_status(
                    status_line(sel, selected, len(lines), level_high, period_ms)
                )
    finally:
        termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old)
        release_all(bcms)
        print("\nReleased GPIOs to input + pull-up. Bye.", flush=True)
    return 0


def main(argv: Optional[list[str]] = None) -> int:
    ap = argparse.ArgumentParser(
        description="J19 GPIO square-wave probe — one status line, ON/OFF toggle"
    )
    ap.add_argument(
        "--period-ms",
        type=int,
        default=DEFAULT_PERIOD_MS,
        help=f"half-period high and low (default {DEFAULT_PERIOD_MS})",
    )
    ap.add_argument(
        "--lines",
        metavar="BCM,...",
        default=None,
        help="override pure-GPIO list, e.g. 2,3,8,9,10,11",
    )
    ap.add_argument(
        "--start",
        type=int,
        default=None,
        help="start on this 1-based line index",
    )
    args = ap.parse_args(argv)

    if args.period_ms < 20:
        print("FATAL: --period-ms minimum is 20", file=sys.stderr)
        return 2

    if not have_pinctrl():
        print(
            "FATAL: pinctrl not found. Run on a Raspberry Pi (Bookworm+).",
            file=sys.stderr,
        )
        return 2

    if args.lines:
        lines = build_lines_from_bcms(parse_lines_arg(args.lines))
    else:
        lines = build_lines_j19()

    return run(lines, args.period_ms, start_index=args.start)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except MapperError as exc:
        print(f"FATAL: {exc}", file=sys.stderr)
        sys.exit(1)
