"""
device_server.py  —  deploy this on the target device (192.168.45.8)

Provides:
  PUT /display   {"state": "on"|"off"}   — blank/unblank framebuffer
  GET /registers                          — read MIPI DSI PHY registers via memtool

Add addresses to REGISTER_COMMANDS to capture more register ranges.
"""

import mmap
import os
import re
import socket
import struct
import subprocess
import threading
import time

from flask import Flask, jsonify, request

app = Flask(__name__)

# ---------------------------------------------------------------------------
# Video playback state (managed as a subprocess)
# ---------------------------------------------------------------------------
KIOSK_SCRIPT = "/root/python/display_test_nexio.py"

_video_proc: subprocess.Popen | None = None
_video_lock = threading.Lock()
_kiosk_args: list[str] = ["python3", KIOSK_SCRIPT]   # updated when kiosk is started

# ---------------------------------------------------------------------------
# Register commands to execute on each GET /registers request.
# Each entry is a complete memtool command string.
# ---------------------------------------------------------------------------
REGISTER_COMMANDS = [
    "memtool md -l 0x32e100b4+0x0c",   # DSIM_PHYTIMING / PHYTIMING1 / PHYTIMING2
]

# ---------------------------------------------------------------------------
# SN65DSI83 I2C configuration
# ---------------------------------------------------------------------------
SN65_I2C_BUS  = 4      # i2c-4 on this board
SN65_I2C_ADDR = 0x2C   # SN65DSI83 fixed 7-bit I2C address

# Settling-period poll — started in a background thread immediately after each
# kiosk kill+restart.  Samples csr_0a and csr_e5 every SETTLING_INTERVAL_S for
# SETTLING_DURATION_S seconds.  Results stored in _settling_log and returned by
# GET /sn65_settling so the host can correlate DSI errors with LP captures.
SETTLING_DURATION_S  = 1.5    # seconds to poll after restart
SETTLING_INTERVAL_S  = 0.010  # 10 ms between I2C reads

_settling_log:  list = []
_settling_lock: threading.Lock = threading.Lock()


# SN65DSI83 configuration registers to snapshot at start and end of each settling window.
# Grouped by purpose so a reset-to-default is obvious at a glance.
# Register address → human-readable name.
_SN65_SNAPSHOT_REGS: dict[int, str] = {
    # Core enable / PLL
    0x09: "CLK_SRC",        # DSI clock source / PLL pre-divider
    0x0A: "PLL_STATUS",     # PLL_EN_STAT (bit7) + CHA_CLK_DET (bit3)  [status]
    0x0D: "PLL_EN",         # bit0 = PLL enable; should be 0x01 when running
    # DSI receiver config
    0x10: "DSI_LANES",      # CHA_DSI_DATA_EQ_SEL + lane count
    0x11: "DSI_CLK_RANGE",  # DSI byte-clock frequency range
    0x12: "LVDS_CLK_RANGE", # LVDS output clock range
    # Active area
    0x18: "HACT_LOW",       # CHA active line length, low byte
    0x19: "HACT_HIGH",      # CHA active line length, high byte
    0x1A: "VACT_LOW",       # CHA vertical display size, low byte
    0x1B: "VACT_HIGH",      # CHA vertical display size, high byte
    # Sync timing
    0x20: "SYNC_DLY_LOW",   # CHA sync delay, low byte
    0x21: "SYNC_DLY_HIGH",  # CHA sync delay, high byte
    0x22: "HSYNC_W_LOW",    # CHA HSYNC pulse width, low byte
    0x23: "HSYNC_W_HIGH",   # CHA HSYNC pulse width, high byte
    0x24: "VSYNC_W_LOW",    # CHA VSYNC pulse width, low byte
    0x25: "VSYNC_W_HIGH",   # CHA VSYNC pulse width, high byte
    0x26: "HBP",            # CHA horizontal back porch
    0x28: "VBP",            # CHA vertical back porch
    0x2A: "HFP",            # CHA horizontal front porch
    0x2C: "VFP",            # CHA vertical front porch
    # Format / output
    0x2D: "REG_0x2D",       # unknown — was mislabeled "TEST_PATTERN" but isn't
    0x3C: "LVDS_FORMAT",    # LVDS output format. bit 4 = CHA_TEST_PATTERN (write 0x10 to enable)
    # Live LVDS line counter — changes every frame when bridge is actively outputting
    0xE0: "LINE_CNT_LOW",   # CHA line count, low byte  [live]
    0xE1: "LINE_CNT_HIGH",  # CHA line count, high byte [live]
    # Error flags
    0xE5: "CHA_ERR",        # DSI error flags            [status]
}


def _sn65_snapshot() -> dict:
    """Read all _SN65_SNAPSHOT_REGS in one pass. Returns {reg_hex: value_hex|None}."""
    result = {}
    for reg, name in _SN65_SNAPSHOT_REGS.items():
        val, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, reg)
        result[f"0x{reg:02x}"] = {"name": name, "value": f"0x{val:02x}" if val is not None else None}
    return result


def _run_settling_poll() -> None:
    """Poll SN65DSI83 csr_0a + csr_e5 at ~10 ms intervals for 1.5 s after restart.
    Also takes a full configuration register snapshot at t=0 and t=end so callers
    can detect bridge re-initialisation or configuration loss."""
    t_start = time.time()
    t_end   = t_start + SETTLING_DURATION_S

    snapshot_start = _sn65_snapshot()

    readings: list = []
    while time.time() < t_end:
        t_ms = round((time.time() - t_start) * 1000, 1)
        val_0a, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A)
        val_e5, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5)
        readings.append({
            "t_ms":      t_ms,
            "csr_0a":    f"0x{val_0a:02x}" if val_0a is not None else None,
            "csr_e5":    f"0x{val_e5:02x}" if val_e5 is not None else None,
            "pll_lock":  bool(val_0a & 0x80) if val_0a is not None else None,
            "clk_det":   bool(val_0a & 0x08) if val_0a is not None else None,
            "any_error": bool(val_e5)         if val_e5 is not None else None,
        })
        time.sleep(SETTLING_INTERVAL_S)

    snapshot_end = _sn65_snapshot()

    with _settling_lock:
        _settling_log.clear()
        _settling_log.extend(readings)
        _settling_extra["snapshot_start"] = snapshot_start
        _settling_extra["snapshot_end"]   = snapshot_end


# Stores the two register snapshots from the most recent settling poll.
_settling_extra: dict = {}

# Known Samsung DSIM register names (base 0x32E10000, i.MX 8M Mini)
_DSIM_NAMES = {
    0x32e10004: "DSIM_STATUS",
    0x32e10008: "DSIM_CLKCTRL",
    0x32e1000c: "DSIM_TIMEOUT",
    0x32e10010: "DSIM_CONFIG",
    0x32e10014: "DSIM_ESCMODE",
    0x32e10018: "DSIM_MDRESOL",
    0x32e1001c: "DSIM_MVPORCH",
    0x32e10020: "DSIM_MHPORCH",
    0x32e10024: "DSIM_MSYNC",
    0x32e10028: "DSIM_SDRESOL",
    0x32e1002c: "DSIM_INTSRC",       # interrupt source — bits latch on event, write-1-clear
    0x32e10030: "DSIM_INTMSK",       # interrupt mask config
    0x32e100ac: "DSIM_PHYACCHR",
    0x32e100b0: "DSIM_PHYACCHR1",
    0x32e100b4: "DSIM_PHYTIMING",
    0x32e100b8: "DSIM_PHYTIMING1",
    0x32e100bc: "DSIM_PHYTIMING2",
}


def _parse_memtool_output(raw: str) -> list:
    """
    Parse 'memtool md -l' output into a list of dicts.

    Handles both formats:
      32e100b4: 00000001 12345678 ...
      0x32e100b4:  0x00000001  0x12345678 ...
    """
    registers = []
    for line in raw.splitlines():
        line = line.strip()
        if not line:
            continue
        m = re.match(r"(?:0x)?([0-9a-fA-F]+)\s*:\s*(.+)", line)
        if not m:
            continue
        base_addr = int(m.group(1), 16)
        values = re.findall(r"[0-9a-fA-F]{8}", m.group(2))
        for i, val in enumerate(values):
            addr = base_addr + i * 4
            registers.append({
                "address": f"0x{addr:08x}",
                "value":   f"0x{val.lower()}",
                "name":    _DSIM_NAMES.get(addr, ""),
            })
    return registers


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@app.route("/display", methods=["PUT"])
def control_display():
    global _video_proc
    data  = request.get_json(force=True) or {}
    state = data.get("state", "").lower()
    if state == "on":
        with _video_lock:
            if "--static-pink" in _kiosk_args:
                # Full kill+restart so the DSI controller goes through LP-11 → HS
                # startup, giving the scope a clean LP trigger on Pass 1.
                if _video_proc is not None and _video_proc.poll() is None:
                    _video_proc.terminate()
                    try:
                        _video_proc.wait(timeout=3)
                    except subprocess.TimeoutExpired:
                        _video_proc.kill()
                        _video_proc.wait()
                time.sleep(0.15)   # let DSI reach LP-11
                # Start settling poll immediately — captures csr_e5 error flags
                # during DSI startup so the host can determine root cause of flicker.
                threading.Thread(target=_run_settling_poll, daemon=True).start()
                try:
                    log = open("/tmp/kiosk.log", "w")
                    _video_proc = subprocess.Popen(
                        _kiosk_args, stdout=log,
                        stderr=subprocess.STDOUT, env=os.environ.copy(),
                    )
                except Exception as e:
                    return jsonify({"error": f"restart failed: {e}"}), 500
                return jsonify({"status": "static-pink restarted"}), 200
            elif _video_proc is not None and _video_proc.poll() is None:
                # Video mode: UDP trigger switches clip and reloads pipeline
                _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                _sock.sendto(b'switch', ('127.0.0.1', 5001))
                _sock.close()
                return jsonify({"status": "video switched"}), 200
        # fallback when no kiosk is running
        os.system("echo 0 > /sys/class/graphics/fb0/blank")
        return jsonify({"status": "Display ON"}), 200
    elif state == "off":
        # nothing to do while video is managing the display
        return jsonify({"status": "ok"}), 200
    else:
        return jsonify({"error": "Invalid state. Use 'on' or 'off'"}), 400


@app.route("/registers", methods=["GET"])
def get_registers():
    """Read MIPI DSI PHY timing registers via memtool and return JSON."""
    all_registers = []
    raw_lines     = []
    errors        = []

    for cmd_str in REGISTER_COMMANDS:
        try:
            result = subprocess.run(
                cmd_str.split(), capture_output=True, text=True, timeout=5
            )
            raw = result.stdout.strip()
            if raw:
                raw_lines.append(raw)
                all_registers.extend(_parse_memtool_output(raw))
            if result.returncode != 0 and result.stderr.strip():
                errors.append(f"{cmd_str}: {result.stderr.strip()}")
        except FileNotFoundError:
            errors.append(f"{cmd_str}: memtool not found in PATH")
        except subprocess.TimeoutExpired:
            errors.append(f"{cmd_str}: timed out after 5 s")
        except Exception as e:
            errors.append(f"{cmd_str}: {e}")

    return jsonify({
        "commands":  REGISTER_COMMANDS,
        "registers": all_registers,
        "raw":       "\n".join(raw_lines),
        "errors":    errors if errors else None,
    }), 200


def _i2c_read_byte(bus: int, addr: int, reg: int) -> tuple[int | None, str]:
    """Read one byte via i2cget. Returns (value, "") on success or (None, error_str) on failure."""
    try:
        result = subprocess.run(
            ["i2cget", "-y", "-f", str(bus), f"0x{addr:02x}", f"0x{reg:02x}"],
            capture_output=True, text=True, timeout=3
        )
        if result.returncode == 0:
            return int(result.stdout.strip(), 16), ""
        stderr = result.stderr.strip() or f"exit code {result.returncode}"
        return None, stderr
    except FileNotFoundError:
        return None, "i2cget not found in PATH"
    except Exception as e:
        return None, str(e)


def _i2c_write_byte(bus: int, addr: int, reg: int, val: int) -> tuple[bool, str]:
    """Write one byte via i2cset. Returns (ok, error_str)."""
    try:
        result = subprocess.run(
            ["i2cset", "-y", "-f", str(bus), f"0x{addr:02x}",
             f"0x{reg:02x}", f"0x{val:02x}"],
            capture_output=True, text=True, timeout=3
        )
        if result.returncode == 0:
            return True, ""
        return False, result.stderr.strip() or f"exit code {result.returncode}"
    except FileNotFoundError:
        return False, "i2cset not found in PATH"
    except Exception as e:
        return False, str(e)


def _read_memtool_words(base_addr: int, n_words: int) -> list:
    """Read n 32-bit words via 'memtool md -l'. Returns list of (addr, value)."""
    try:
        cmd = ["memtool", "md", "-l", f"0x{base_addr:08x}+0x{n_words*4:x}"]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
        if result.returncode != 0:
            return []
        return [(int(r["address"], 16), int(r["value"], 16))
                for r in _parse_memtool_output(result.stdout)]
    except Exception:
        return []


# DSIM register blocks worth watching. Two contiguous ranges → 2 memtool calls per snapshot.
# Block 1: status / config / timing / interrupts (0x004-0x030)
#          STATUS, CLKCTRL, TIMEOUT, CONFIG, ESCMODE, MDRESOL, MVPORCH, MHPORCH,
#          MSYNC, SDRESOL, INTSRC, INTMSK
# Block 2: PHY (0xAC-0xBC)
#          PHYACCHR, PHYACCHR1, PHYTIMING, PHYTIMING1, PHYTIMING2
_DSIM_SNAPSHOT_BLOCKS = [
    (0x32e10004, 12),
    (0x32e100ac, 5),
]


def _dsim_snapshot() -> dict:
    """Read DSIM status/config/PHY registers via memtool.
    Returns {address_hex: {name, value}} or value=None on read failure."""
    snapshot = {}
    for base, n in _DSIM_SNAPSHOT_BLOCKS:
        words = _read_memtool_words(base, n)
        # If read failed entirely, log Nones for each expected address so a diff still surfaces
        if not words:
            for i in range(n):
                addr = base + i * 4
                snapshot[f"0x{addr:08x}"] = {
                    "name":  _DSIM_NAMES.get(addr, ""),
                    "value": None,
                }
            continue
        for addr, val in words:
            snapshot[f"0x{addr:08x}"] = {
                "name":  _DSIM_NAMES.get(addr, ""),
                "value": f"0x{val:08x}",
            }
    return snapshot


@app.route("/sn65_settling", methods=["GET"])
def get_sn65_settling():
    """Return the most recent post-restart settling poll.

    Includes:
      snapshot_start  — full register dump taken immediately before polling begins
      snapshot_end    — full register dump taken immediately after polling ends
      readings        — csr_0a + csr_e5 sampled every ~10 ms during the window
    """
    with _settling_lock:
        readings       = list(_settling_log)
        snap_start     = dict(_settling_extra.get("snapshot_start") or {})
        snap_end       = dict(_settling_extra.get("snapshot_end")   or {})

    error_readings = [r for r in readings if r.get("any_error")]

    # Diff the two snapshots so the caller can immediately see what changed.
    changed = {}
    for reg, info_s in snap_start.items():
        info_e = snap_end.get(reg, {})
        v_s = info_s.get("value")
        v_e = info_e.get("value")
        if v_s != v_e:
            changed[reg] = {"name": info_s.get("name"), "start": v_s, "end": v_e}

    return jsonify({
        "n_readings":     len(readings),
        "n_error":        len(error_readings),
        "duration_s":     SETTLING_DURATION_S,
        "interval_ms":    int(SETTLING_INTERVAL_S * 1000),
        "snapshot_start": snap_start,
        "snapshot_end":   snap_end,
        "changed_regs":   changed,
        "readings":       readings,
    }), 200


@app.route("/sn65_registers", methods=["GET"])
def get_sn65_registers():
    """Read SN65DSI83 CSR 0x0A (PLL/CLK status) and 0xE5 (error flags) via I2C."""
    csr_0a, err_0a = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A)
    csr_e5, err_e5 = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5)

    regs   = {}
    errors = []

    if csr_0a is not None:
        regs["csr_0a"] = {
            "value":    f"0x{csr_0a:02x}",
            "pll_lock": bool(csr_0a & 0x80),   # bit 7: PLL_EN_STAT (powered + locked)
            "clk_det":  bool(csr_0a & 0x08),   # bit 3: CHA_CLK_DET (HS clock detected)
        }
    else:
        errors.append(f"CSR 0x0A: {err_0a}")

    if csr_e5 is not None:
        regs["csr_e5"] = {
            "value":           f"0x{csr_e5:02x}",
            "pll_unlock":      bool(csr_e5 & 0x01),  # default=1 at reset; must clear after init
            "cha_sot_bit_err": bool(csr_e5 & 0x04),
            "cha_llp_err":     bool(csr_e5 & 0x08),  # SoT/EoT/lane-merge error
            "cha_ecc_err":     bool(csr_e5 & 0x10),
            "cha_lp_err":      bool(csr_e5 & 0x20),
            "cha_crc_err":     bool(csr_e5 & 0x40),
        }
    else:
        errors.append(f"CSR 0xE5: {err_e5}")

    return jsonify({
        "i2c_bus":   SN65_I2C_BUS,
        "i2c_addr":  f"0x{SN65_I2C_ADDR:02x}",
        "registers": regs,
        "errors":    errors if errors else None,
    }), 200


# ---------------------------------------------------------------------------
# High-rate PLL monitor — runs on-device so we sample at ~10 ms instead of
# the ~55 ms HTTP-polling could achieve. Logs only transitions (unlock /
# recovered) so the event log stays small.
# ---------------------------------------------------------------------------
PLL_MONITOR_DEFAULT_MS = 10
PLL_MONITOR_MAX_EVENTS = 10000

_pll_monitor_thread:  threading.Thread | None = None
_pll_wide_thread:     threading.Thread | None = None
_dsim_fast_thread:    threading.Thread | None = None
_pll_monitor_stop:    threading.Event = threading.Event()
_pll_monitor_lock:    threading.Lock  = threading.Lock()
_pll_monitor_events:  list = []
_pll_monitor_stats:   dict = {
    "running": False, "interval_ms": 0, "wide_interval_ms": 0,
    "fast_dsim_interval_ms": 0, "fast_dsim_polls": 0, "fast_dsim_error": None,
    "polls": 0, "errors": 0, "wide_polls": 0, "started_at": None,
}

# ---------------------------------------------------------------------------
# Fast DSIM register poller via /dev/mem mmap. Bypasses the memtool subprocess
# overhead so we can poll at sub-millisecond resolution and catch transient
# register changes that the 500 ms wide loop would miss.
# ---------------------------------------------------------------------------
DSIM_BASE      = 0x32E10000
DSIM_PAGE_SIZE = 0x1000        # one 4 KB page covers offsets 0x000-0xFFF

# Register offsets within the DSIM page that we want to watch.
_DSIM_FAST_REGS = {
    0x004: "DSIM_STATUS",      # flutters with frame counter — excluded from diff
    0x008: "DSIM_CLKCTRL",
    0x00c: "DSIM_TIMEOUT",
    0x010: "DSIM_CONFIG",
    0x014: "DSIM_ESCMODE",
    0x018: "DSIM_MDRESOL",
    0x01c: "DSIM_MVPORCH",
    0x020: "DSIM_MHPORCH",
    0x024: "DSIM_MSYNC",
    0x028: "DSIM_SDRESOL",
    0x02c: "DSIM_INTSRC",
    0x030: "DSIM_INTMSK",
    0x0ac: "DSIM_PHYACCHR",
    0x0b0: "DSIM_PHYACCHR1",
    0x0b4: "DSIM_PHYTIMING",
    0x0b8: "DSIM_PHYTIMING1",
    0x0bc: "DSIM_PHYTIMING2",
}

# DSIM_STATUS flutters every frame so a naive diff drowns out everything else.
# Exclude it from the diff. (Reading it still happens — the value is captured
# in the event payload for any iteration that flagged a change in another reg.)
_DSIM_FAST_SKIP = {0x004}

# Registers that change every frame — exclude from diff so we don't drown in noise.
# SN65 uses short hex (e.g. "0xe0"); DSIM uses long hex (e.g. "0x32e10004") so no collisions.
_PLL_WIDE_LIVE_REGS = {
    "0xe0", "0xe1",           # SN65 LINE_CNT_LOW / LINE_CNT_HIGH
    # DSIM_STATUS bits 0-3 are FRAME_DONE/BUSY/TX_READY-style flags that fluctuate
    # every frame, so the whole register is treated as live by default. Comment out
    # if you want to see all its changes (and accept the noise).
    "0x32e10004",             # DSIM_STATUS
}


def _classify_sn65(val_0a: int, val_e5: int) -> tuple[bool, list]:
    """Returns (any_error, flag_list). pll_lock=False is included as a flag."""
    pll_locked = bool(val_0a & 0x80)
    flags = []
    if not pll_locked:        flags.append("pll_lock_false")
    if val_e5 & 0x01:         flags.append("pll_unlock")
    if val_e5 & 0x04:         flags.append("cha_sot_bit_err")
    if val_e5 & 0x08:         flags.append("cha_llp_err")
    if val_e5 & 0x10:         flags.append("cha_ecc_err")
    if val_e5 & 0x20:         flags.append("cha_lp_err")
    if val_e5 & 0x40:         flags.append("cha_crc_err")
    return bool(flags), flags


def _pll_monitor_loop(interval_ms: int) -> None:
    interval_s = interval_ms / 1000.0
    last_bad   = False

    with _pll_monitor_lock:
        _pll_monitor_stats.update(running=True, interval_ms=interval_ms,
                                  polls=0, errors=0, started_at=time.time())
        _pll_monitor_events.clear()

    while not _pll_monitor_stop.is_set():
        t0 = time.time()

        val_0a, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x0A)
        val_e5, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0xE5)

        with _pll_monitor_lock:
            _pll_monitor_stats["polls"] += 1
            if val_0a is None or val_e5 is None:
                _pll_monitor_stats["errors"] += 1
            else:
                bad, flags = _classify_sn65(val_0a, val_e5)
                if bad and not last_bad:
                    _pll_monitor_events.append({
                        "t":      t0,
                        "type":   "unlock",
                        "csr_0a": f"0x{val_0a:02x}",
                        "csr_e5": f"0x{val_e5:02x}",
                        "flags":  flags,
                    })
                elif (not bad) and last_bad:
                    _pll_monitor_events.append({
                        "t":      t0,
                        "type":   "recovered",
                        "csr_0a": f"0x{val_0a:02x}",
                        "csr_e5": f"0x{val_e5:02x}",
                    })
                last_bad = bad

                if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
                    del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]

        elapsed = time.time() - t0
        if elapsed < interval_s:
            time.sleep(interval_s - elapsed)

    with _pll_monitor_lock:
        _pll_monitor_stats["running"] = False


def _pll_wide_loop(interval_ms: int) -> None:
    """Background thread: snapshot all SN65 config/status registers AND the
    DSIM status/PHY register block at interval_ms cadence, log a single
    'register_change' event per poll containing any diffs vs previous snapshot.
    Excludes registers in _PLL_WIDE_LIVE_REGS from the diff."""
    interval_s = interval_ms / 1000.0

    def _combined_snapshot() -> dict:
        snap = _sn65_snapshot()
        snap.update(_dsim_snapshot())
        return snap

    prev = _combined_snapshot()   # baseline

    with _pll_monitor_lock:
        _pll_monitor_stats["wide_interval_ms"] = interval_ms
        _pll_monitor_stats["wide_polls"] = 0

    while not _pll_monitor_stop.is_set():
        t0  = time.time()
        cur = _combined_snapshot()

        changes = {}
        for reg, info in cur.items():
            if reg in _PLL_WIDE_LIVE_REGS:
                continue
            prev_info = prev.get(reg, {})
            if info.get("value") != prev_info.get("value"):
                changes[reg] = {
                    "name": info.get("name"),
                    "from": prev_info.get("value"),
                    "to":   info.get("value"),
                }

        with _pll_monitor_lock:
            _pll_monitor_stats["wide_polls"] += 1
            if changes:
                _pll_monitor_events.append({
                    "t":       t0,
                    "type":    "register_change",
                    "changes": changes,
                })
                if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
                    del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]

        prev = cur

        elapsed = time.time() - t0
        if elapsed < interval_s:
            time.sleep(interval_s - elapsed)


def _dsim_fast_loop(interval_ms: int) -> None:
    """High-rate DSIM register poller using /dev/mem mmap.
    Reads ONLY DSIM_CLKCTRL each iteration (the register known to flip
    sub-millisecond). Other registers are left to the 500 ms wide loop —
    this keeps the fast loop's CPU cost low enough not to starve Flask.
    Logs every CLKCTRL transition as a 'dsim_fast_change' event."""
    interval_s = max(interval_ms, 1) / 1000.0
    try:
        fd = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
    except OSError as e:
        with _pll_monitor_lock:
            _pll_monitor_stats["fast_dsim_error"] = f"open /dev/mem failed: {e}"
        return
    try:
        mm = mmap.mmap(fd, DSIM_PAGE_SIZE, mmap.MAP_SHARED, mmap.PROT_READ,
                       offset=DSIM_BASE)
    except (OSError, ValueError) as e:
        os.close(fd)
        with _pll_monitor_lock:
            _pll_monitor_stats["fast_dsim_error"] = f"mmap DSIM page failed: {e}"
        return

    CLKCTRL_OFFSET = 0x008
    CLKCTRL_ADDR   = f"0x{DSIM_BASE + CLKCTRL_OFFSET:08x}"

    prev = struct.unpack_from("<I", mm, CLKCTRL_OFFSET)[0]
    polls = 0

    with _pll_monitor_lock:
        _pll_monitor_stats["fast_dsim_interval_ms"] = interval_ms
        _pll_monitor_stats["fast_dsim_polls"] = 0
        _pll_monitor_stats["fast_dsim_error"] = None

    try:
        while not _pll_monitor_stop.is_set():
            try:
                t0 = time.time()
                polls += 1
                cur = struct.unpack_from("<I", mm, CLKCTRL_OFFSET)[0]

                if cur != prev:
                    with _pll_monitor_lock:
                        _pll_monitor_events.append({
                            "t":    t0,
                            "type": "dsim_fast_change",
                            "changes": {
                                CLKCTRL_ADDR: {
                                    "name": "DSIM_CLKCTRL",
                                    "from": f"0x{prev:08x}",
                                    "to":   f"0x{cur:08x}",
                                }
                            },
                        })
                        if len(_pll_monitor_events) > PLL_MONITOR_MAX_EVENTS:
                            del _pll_monitor_events[:len(_pll_monitor_events) - PLL_MONITOR_MAX_EVENTS]
                    prev = cur

                # Push polls counter to stats periodically
                if polls % 1000 == 0:
                    with _pll_monitor_lock:
                        _pll_monitor_stats["fast_dsim_polls"] = polls

                elapsed = time.time() - t0
                if elapsed < interval_s:
                    time.sleep(interval_s - elapsed)
            except Exception as e:
                # Don't die silently; surface the error as an event and keep polling
                with _pll_monitor_lock:
                    _pll_monitor_stats["fast_dsim_error"] = f"iter {polls}: {type(e).__name__}: {e}"
                    _pll_monitor_events.append({
                        "t":    time.time(),
                        "type": "dsim_fast_error",
                        "error": str(e),
                    })
                time.sleep(0.1)  # back off briefly so we don't spin on a persistent error
    finally:
        with _pll_monitor_lock:
            _pll_monitor_stats["fast_dsim_polls"] = polls
        mm.close()
        os.close(fd)


@app.route("/pll_monitor", methods=["PUT"])
def control_pll_monitor():
    """Start, stop, or clear the device-side PLL event monitor.

    PUT /pll_monitor {"action":"start","interval_ms":10,"wide_interval_ms":500}
    PUT /pll_monitor {"action":"stop"}
    PUT /pll_monitor {"action":"clear"}

    wide_interval_ms (optional, default 0 = disabled): if > 0, also runs a
    second thread that snapshots all SN65 config/status registers at that
    cadence and logs 'register_change' events on any non-trivial diff.
    """
    global _pll_monitor_thread, _pll_wide_thread, _dsim_fast_thread
    data   = request.get_json(force=True) or {}
    action = data.get("action", "").lower()

    if action == "start":
        if _pll_monitor_thread is not None and _pll_monitor_thread.is_alive():
            return jsonify({"status": "already_running", "device_now": time.time()}), 200
        interval_ms           = max(5,  int(data.get("interval_ms", PLL_MONITOR_DEFAULT_MS)))
        wide_interval_ms      = max(0,  int(data.get("wide_interval_ms", 0)))
        fast_dsim_interval_ms = max(0,  int(data.get("fast_dsim_interval_ms", 0)))
        _pll_monitor_stop.clear()
        _pll_monitor_thread = threading.Thread(
            target=_pll_monitor_loop, args=(interval_ms,), daemon=True
        )
        _pll_monitor_thread.start()
        if wide_interval_ms > 0:
            _pll_wide_thread = threading.Thread(
                target=_pll_wide_loop, args=(wide_interval_ms,), daemon=True
            )
            _pll_wide_thread.start()
        else:
            _pll_wide_thread = None
        if fast_dsim_interval_ms > 0:
            _dsim_fast_thread = threading.Thread(
                target=_dsim_fast_loop, args=(fast_dsim_interval_ms,), daemon=True
            )
            _dsim_fast_thread.start()
        else:
            _dsim_fast_thread = None
        return jsonify({"status": "started", "interval_ms": interval_ms,
                        "wide_interval_ms": wide_interval_ms,
                        "fast_dsim_interval_ms": fast_dsim_interval_ms,
                        "device_now": time.time()}), 200

    elif action == "stop":
        _pll_monitor_stop.set()
        if _pll_monitor_thread is not None:
            _pll_monitor_thread.join(timeout=2)
        if _pll_wide_thread is not None:
            _pll_wide_thread.join(timeout=2)
        if _dsim_fast_thread is not None:
            _dsim_fast_thread.join(timeout=2)
        return jsonify({"status": "stopped"}), 200

    elif action == "clear":
        with _pll_monitor_lock:
            _pll_monitor_events.clear()
        return jsonify({"status": "cleared"}), 200

    else:
        return jsonify({"error": "Invalid action. Use 'start', 'stop', or 'clear'"}), 400


@app.route("/pll_monitor/events", methods=["GET"])
def get_pll_monitor_events():
    """Return events with t > since (epoch seconds float), plus stats + device_now."""
    since_raw = request.args.get("since", default="0")
    try:
        since_f = float(since_raw)
    except ValueError:
        since_f = 0.0
    with _pll_monitor_lock:
        events = [e for e in _pll_monitor_events if e["t"] > since_f]
        stats  = dict(_pll_monitor_stats)
    return jsonify({"events": events, "stats": stats, "device_now": time.time()}), 200


@app.route("/sn65_testpattern", methods=["PUT"])
def control_testpattern():
    """Enable/disable the SN65DSI83 internal LVDS test pattern (CSR 0x2D bit 0).

    PUT /sn65_testpattern {"state":"on"|"off"}

    Test pattern is generated inside the SN65 at the LVDS output stage, downstream
    of the MIPI input and conversion logic. Used to bisect flicker root cause:
    if test pattern is clean while MIPI input flickers, fault is upstream of LVDS.
    """
    data  = request.get_json(force=True) or {}
    state = data.get("state", "").lower()
    if state not in ("on", "off"):
        return jsonify({"error": "Invalid state. Use 'on' or 'off'"}), 400

    # Known-working sequence (user-confirmed):
    #   i2cset -y -f 4 0x2c 0x3c 0x10   # enable
    #   i2cset -y -f 4 0x2c 0x3c 0x00   # disable
    # Test pattern is bit 4 of CSR 0x3C (LVDS_FORMAT). Write whole byte to match.
    current, err = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C)
    if current is None:
        return jsonify({"error": f"read 0x3C failed: {err}"}), 500

    new = 0x10 if state == "on" else 0x00
    ok, werr = _i2c_write_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C, new)
    if not ok:
        return jsonify({"error": f"write 0x3C failed: {werr}"}), 500

    verify, _ = _i2c_read_byte(SN65_I2C_BUS, SN65_I2C_ADDR, 0x3C)
    return jsonify({
        "status":   "ok",
        "state":    state,
        "register": "0x3C",
        "before":   f"0x{current:02x}",
        "after":    f"0x{verify:02x}" if verify is not None else None,
    }), 200


@app.route("/video", methods=["PUT"])
def control_video():
    """Start or stop the kiosk video player.

    PUT /video   {"action": "start"|"stop"}
    """
    global _video_proc
    data   = request.get_json(force=True) or {}
    action = data.get("action", "").lower()

    with _video_lock:
        if action == "start":
            if _video_proc is not None and _video_proc.poll() is None:
                return jsonify({"status": "already_running", "pid": _video_proc.pid}), 200
            try:
                cmd = ["python3", KIOSK_SCRIPT]
                mode = data.get("mode", "")
                if mode == "static-pink":
                    cmd.append("--static-pink")
                video = data.get("video")
                if video:
                    cmd.extend(["--start", video])
                _kiosk_args[:] = cmd   # persist so control_display knows the mode
                log = open("/tmp/kiosk.log", "w")
                _video_proc = subprocess.Popen(
                    cmd,
                    stdout=log,
                    stderr=subprocess.STDOUT,
                    env=os.environ.copy(),
                )
            except Exception as e:
                return jsonify({"error": f"failed to launch kiosk: {e}"}), 500
            return jsonify({"status": "started", "mode": mode or "video", "pid": _video_proc.pid}), 200

        elif action == "stop":
            if _video_proc is not None and _video_proc.poll() is None:
                _video_proc.terminate()
                try:
                    _video_proc.wait(timeout=3)
                except subprocess.TimeoutExpired:
                    _video_proc.kill()
                    _video_proc.wait()
            _video_proc = None
            return jsonify({"status": "stopped"}), 200

        else:
            return jsonify({"error": "Invalid action. Use 'start' or 'stop'"}), 400


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
