#!/usr/bin/env python3
r"""lcxl3-probe-display — WHEN does the LCXL3's OLED actually repaint?

A guided, eye-in-the-loop probe. It sends nothing but display/config SysEx, tells
PLN exactly what to look at, asks what he saw, and writes the answers to JSON so
the session that reads them is reading *observations*, not a theory.

WHY THIS FILE EXISTS
    The v3 display work has now lost the same argument four times, and each loss
    had the same shape: we assumed something about *when* the panel re-renders,
    built on the assumption, and only found out it was false from PLN's eyes.
    Round 4 (`armada/tasks/035-thirty-four-screens.md`) finally read the chapter
    and found thirty-four display targets instead of two. That was a real win,
    and it left one assumption still standing, untested, load-bearing, and now
    contradicted by two independent symptoms.

    THE STANDING ASSUMPTION, quoted from the code that depends on it —
    `tools/lcxl3-driver.py`, in the 10-second home tick:

        # tick the lap clock on home row 2; a field write on the
        # stationary target needs no re-summon
        self.surface.set_text(L.TGT_STATIONARY, 1, self._home_detail())

    "needs no re-summon" is a hypothesis wearing a comment's clothes
    (feedback_stated_invariant_is_a_hypothesis). Nobody ever watched the glass
    for eleven seconds to check it.

    TWO SYMPTOMS, ONE SUSPECT
    (a) The lap clock on the home screen is frozen at `0:00` on the device,
        while the driver's ghost mirror (/tmp/lcxl3-oled-ghost.txt) ticks
        correctly. The ghost mirrors what we SENT. So the bytes go out and the
        pixels do not move. `oled_home()` sends a bring-up (`configure_display
        (0x35, 0x7F)`) exactly ONCE, at startup — every subsequent lap-clock
        update is a bare `set_text`.
    (b) The DJ-filter Hz readout on the global temporary target (0x36) refreshes
        only at the driver's ~0.9 s re-summon cadence, never between. PLN: "I see
        the Hz but it refreshes maybe every 1s, not smooth at all." Look at
        `oled_touch()`: between summons it sends `set_text` and nothing else. So
        the one moment the display changes is the one moment a bring-up is sent.

    Both symptoms are explained by a single mechanism: **a text field written to
    an already-visible display target does not repaint it; the target has to be
    re-summoned (config byte 0x7F, "bring up the display with its current
    contents") before the new field contents reach the panel.** If that is true,
    the lap clock is a one-line fix and smooth Hz is a one-line fix, and both
    fixes are the same line.

    But "explained by" is not "caused by", so the probe also has to be able to
    come back with the other answers: that 0x35 never repaints for any pattern
    we can send (a different bug entirely), or that a bring-up must come BEFORE
    the write rather than after (in which case the driver's summon ordering is
    backwards and always was), or that the panel repaints fine and the frozen
    clock is a stale-string bug upstream in `_home_detail()`.

HYPOTHESES THIS PROBE DISTINGUISHES
    H1-NONE    A visible target repaints on a bare field write. (The comment in
               the driver is right; the frozen clock is somebody else's bug.)
    H1-AFTER   A field write reaches the panel only when followed by a bring-up.
               (Predicted. Fixes both symptoms with one call, in both places.)
    H1-BEFORE  Only a bring-up that PRECEDES the writes renders them — i.e. the
               bring-up latches a frame and writes land in the next frame.
    H1-NEITHER 0x35 does not animate under any of the three patterns; the
               stationary target may be write-once-per-configure.
    H2-RATE    The fastest bring-up cadence that still renders cleanly. Decides
               whether the DJF Hz readout can be smooth at all, and at what
               cost in SysEx per second. Three rates, PLN's adjectives.
    H3-IDLE    A per-control target (0x05-0x24) can be repainted while its
               control is untouched — or only the firmware's own auto-trigger
               ever brings it up.
    H3-BIT5 / H3-BIT6
               Which suppression bit actually matters. Bit 6 = auto-display on
               Change, bit 5 = auto-display on Touch (the guide says touch here
               means Shift + rotate, not a capacitive sensor). The driver clears
               BOTH on the three DJF knobs; if only one of them was doing the
               work we are throwing away a display we could have used.
    H4-ARR     The arrangement census. Bits 0-4 of the config byte hold 0..31;
               the guide's table documents 0 (cancel), 1 (Name + our text
               value), 2 (Title + Name + our text value), 3 (Title + 8 names),
               4 (Name + FIRMWARE-drawn numeric value) and 31 (trigger).
               5..30 are undocumented — they may be real, or they may be
               silently ignored. Two things we want out of the census:
                 (a) an arrangement where the HOST supplies the value string,
                     so the panel can read "37%" or "2.4kHz" instead of the
                     firmware's raw 0-127 wire value (which for a relative
                     encoder is a permanent, confident lie);
                 (b) which arrangement puts the NAME on the TOP line. Right now
                     our per-control line 1 renders empty and PLN, seated low,
                     reads rows 1-2 and cannot see row 3 at all (the screen's
                     bottom bezel eats it — measured from his chair 2026-08-29,
                     which is why `lcxl3_language.home_lines` already puts the
                     row that matters SECOND). An empty top row is the most
                     legible row on the panel, wasted.
    H5-TRIGGER Is the "bring-up" byte 0x7F actually arrangement 31 (0x1F) with
               bits 5 and 6 set? 0x7F == 0b1111111 says yes arithmetically. If
               the firmware reads the whole byte, then every bring-up we send to
               a per-control target RE-ARMS the auto-display bits we cleared —
               which would silently undo the DJF suppression that
               `oled_labels()` sets up. Cheap to test, nasty if true.

WHAT THIS PROBE WILL NOT DO
    Send a single CC or note. Not one. The surface's CCs are wired into a LIVE
    SuperDirt rig and a stray CC changes what a room hears; CC93 is the panic
    kill. So the transport here is display/config SysEx only, and the Surface
    object has its `cc()` and `note()` methods replaced with raisers at open
    time — a bug in this file becomes a traceback, not a sound. It also touches
    no ALSA connection: it opens its own output port and never prunes anything.

    There is NO DISPLAY READBACK in this protocol. The device never tells us
    what it is showing. That is the whole reason this file is interactive: PLN's
    eyes are the only instrument, so the tool's job is to make the question
    small enough that an eye can answer it.

USAGE
    # review the whole probe without a device, byte for byte
    python3 tools/lcxl3-probe-display.py --dry-run

    # at the desk, driver stopped, reading the OLED
    python3 tools/lcxl3-probe-display.py

    # just the arrangement census, all 30 candidate values
    python3 tools/lcxl3-probe-display.py --only 4 --full-census

Answers persist to --out (default: this session's scratchpad) as JSON.
"""

from __future__ import annotations

import argparse
import datetime as _dt
import json
import pathlib
import subprocess
import sys
import textwrap
import time

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

import lcxl3 as L  # noqa: E402

DEFAULT_OUT = ("/tmp/claude-1000/-home-pln-Work-Sound-Tidal/"
               "0f7ff2b8-ccb4-40d8-b86c-5741f929f419/scratchpad/"
               "display-probe-results.json")

# The per-control target the probe pokes. 0x15 == 21 == encoder row B col 1.
#
# Chosen, not defaulted-into: row B is fx-per-orbit in lcxl_grid, so it is NOT
# in ARDOUR_CCS (which is all 8 faders plus A1-A4 = the d9-d12 levels). Steps 3
# and 5 ask PLN to physically turn this knob; if the raw DAW port happens to be
# connected to something, a row-B nudge moves an effect on one orbit and not an
# Ardour fader or a track level. It also has an LED ring and it rotates, which
# a fader (targets 0x05-0x0C, no LEDs) does and does not respectively.
DEFAULT_CONTROL = 0x15

# What the driver leaves the device in, and therefore what we must hand back:
#   * every per-control target at ARR_NAME_NUMVAL with bits 5+6 set —
#     `Driver.stop()` calls `native_overlay(True)` for exactly this reason
#   * the stationary target at arrangement 2 — `oled_home()` sends
#     `configure_display(L.TGT_STATIONARY, 2)`, a bare 2, so bits 5+6 clear
DRIVER_STATIONARY_CFG = 2
DRIVER_CONTROL_ARRANGEMENT = L.ARR_NAME_NUMVAL


# ------------------------------------------------------------------- wire ----


class Wire:
    """The only thing in this file allowed to talk to the device.

    Every send is logged as the full F0..F7 byte string with the hypothesis it
    serves, so --dry-run is a complete, reviewable transcript and a real run
    leaves the same transcript in the JSON next to PLN's answers. If a step's
    result later looks wrong, the bytes that produced it are right there.
    """

    def __init__(self, surface, dry: bool):
        self.s = surface
        self.dry = dry
        self.log: list[str] = []
        self._why = ""

    def why(self, text: str) -> None:
        """Name the hypothesis the next few sends are testing."""
        self._why = text
        line = f"# {text}"
        self.log.append(line)
        if self.dry:
            print(f"    {line}")

    def _emit(self, payload: list[int], note: str) -> None:
        hexs = " ".join(f"{b:02X}" for b in
                        [0xF0, *L.HEADER[1:], *payload, 0xF7])
        self.log.append(f"{hexs}   {note}")
        if self.dry:
            print(f"    {hexs}   {note}")
            return
        self.s.sysex(*payload)

    def config(self, target: int, cfg: int, note: str = "") -> None:
        arr = cfg & 0x1F
        bits = []
        if cfg & L.CFG_AUTO_CHANGE:
            bits.append("bit6/change")
        if cfg & L.CFG_AUTO_TOUCH:
            bits.append("bit5/touch")
        if cfg == L.CFG_BRING_UP:
            what = "BRING-UP (0x7F)"
        elif cfg == L.CFG_CANCEL:
            what = "CANCEL (0x00)"
        elif arr == L.ARR_TRIGGER:
            what = f"trigger arr31, {'+'.join(bits) or 'no auto bits'}"
        else:
            what = f"arr {arr}, {'+'.join(bits) or 'no auto bits'}"
        self._emit([L.CMD_CONFIG_DISPLAY, target, cfg & 0x7F],
                   note or f"configure_display(0x{target:02X}) -> {what}")

    def text(self, target: int, field: int, s: str, note: str = "") -> None:
        data = [ord(c) & 0x7F for c in s
                if 0x20 <= ord(c) <= 0x7E or 0x1B <= ord(c) <= 0x1E]
        self._emit([L.CMD_SET_TEXT, target, field, *data],
                   note or f"set_text(0x{target:02X}, field {field}, {s!r})")

    def daw_mode(self, on: bool) -> None:
        self._emit([L.CMD_DAW_MODE, 0x7F if on else 0x00],
                   f"DAW mode -> {'ON' if on else 'OFF'} "
                   "(the only mode that paints or draws)")

    def sleep(self, secs: float) -> None:
        if self.dry:
            self.log.append(f"# (wait {secs:.2f}s)")
            print(f"    # (wait {secs:.2f}s)")
            return
        time.sleep(secs)


def _disarm(surface) -> None:
    """Make the live-rig safety rule mechanical instead of aspirational.

    Never send a CC or a note from this file: the surface's CC stream feeds a
    live SuperDirt rig, and CC93 is gPanic. Replacing the methods means a future
    edit that forgets gets a traceback at the desk instead of a sound in a room
    (feedback_verify_before_claiming_safe — a negative grep is not a guarantee).
    """

    def _refuse(*_a, **_k):
        raise RuntimeError(
            "lcxl3-probe-display tried to emit a CC or note. This probe is "
            "display/config SysEx ONLY — the surface's CCs are wired into a "
            "live rig and CC93 is the panic kill. Fix the caller.")

    surface.cc = _refuse
    surface.note = _refuse
    surface.feature = _refuse
    surface.palette = _refuse
    surface.rgb = _refuse


# ------------------------------------------------------------------- asking ----


class Asker:
    """PLN's eyes, as an input device. Records every answer with its question."""

    def __init__(self, results: list[dict], dry: bool):
        self.results = results
        self.dry = dry
        self.aborted = False

    def _read(self, prompt: str) -> str:
        try:
            return input(prompt).strip()
        except EOFError:
            print("\n(no tty — treating as skip)")
            return "s"

    def yn(self, step: str, hypothesis: str, question: str) -> str:
        """-> 'y' | 'n' | 'skip'. 'q' aborts the run (restore still happens).

        ONE prompt, one line: the answer may carry its own note after the
        letter ("n it flashed once then went blank"). A second "anything to
        add?" prompt per question sounded thorough on paper and is 25 extra
        Enters at the desk — and every extra prompt is a chance for PLN's
        typing to drift out of step with what the screen is doing, which would
        silently mis-file an observation. Cheap notes, no extra keystroke.
        """
        if self.dry or self.aborted:
            self._record(step, hypothesis, question, "dry-run" if self.dry
                         else "aborted", "")
            return "skip"
        while True:
            raw = self._read(f"    {question}\n    [y/n/s(kip)/q(uit)"
                             " — add a comment after the letter if you like] ")
            head, _, note = raw.partition(" ")
            head = head.strip().lower()
            if head.startswith("q"):
                self.aborted = True
                self._record(step, hypothesis, question, "aborted", note.strip())
                return "skip"
            if head[:1] in ("y", "n", "s"):
                ans = {"y": "yes", "n": "no", "s": "skip"}[head[0]]
                self._record(step, hypothesis, question, ans, note.strip())
                return {"yes": "y", "no": "n", "skip": "skip"}[ans]
            print("    y, n, s or q please (optionally followed by a comment).")

    def text(self, step: str, hypothesis: str, question: str) -> str:
        if self.dry or self.aborted:
            self._record(step, hypothesis, question, "dry-run" if self.dry
                         else "aborted", "")
            return ""
        a = self._read(f"    {question}\n    > ")
        if a.lower() in ("q", "quit"):
            self.aborted = True
            a = "aborted"
        self._record(step, hypothesis, question, a, "")
        return a

    def _record(self, step: str, hypothesis: str, question: str,
                answer: str, note: str) -> None:
        self.results.append({
            "step": step,
            "hypothesis": hypothesis,
            "question": question,
            "answer": answer,
            "note": note,
        })


def banner(title: str, why: str) -> None:
    print()
    print("=" * 72)
    print(title)
    print("=" * 72)
    # The `why` blocks open on the same line as their triple quote, so a plain
    # dedent would leave line 0 flush and the rest indented. Dedent the tail
    # jointly instead, which preserves the sub-bullets the census banner relies
    # on to stay readable.
    lines = why.strip("\n").splitlines()
    head, tail = lines[0].strip(), textwrap.dedent("\n".join(lines[1:]))
    print(f"  {head}")
    for line in tail.splitlines():
        print(f"  {line}".rstrip())
    print()


def hold(msg: str, dry: bool) -> None:
    if dry:
        return
    try:
        input(f"  {msg} ")
    except EOFError:
        pass


# -------------------------------------------------------------------- steps ----


def step1_stationary(w: Wire, a: Asker, args) -> None:
    """H1 — does a bare field write repaint an already-visible 0x35?

    The minimal distinguishing test is a counter, because a counter separates
    "did not render" from "rendered the same thing": if PLN sees 1 and then
    still 1 while our log says we sent 2,3,4,5, the panel is not repainting.
    One tick per second is slow enough that a human can be sure.
    """
    banner("STEP 1 — the home screen (stationary target 0x35)",
           """The driver's lap clock updates row 2 with a bare set_text every 10 s
              and PLN sees 0:00 forever. Three patterns, same counter. Whichever
              one animates is the one the driver should be using.""")

    ticks = args.ticks

    # Baseline: one full configure + write + bring-up, exactly like oled_home().
    w.why("H1 setup — establish a VISIBLE 0x35, the way the driver does at boot")
    w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
    w.text(L.TGT_STATIONARY, 0, "PROBE 1")
    w.text(L.TGT_STATIONARY, 1, "COUNT 0")
    w.text(L.TGT_STATIONARY, 2, "ParVagues")
    w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
    hold("The home screen should read PROBE 1 / COUNT 0 / ParVagues. enter...",
         w.dry)

    w.why("H1-NONE — bare set_text to a visible target, NO bring-up. This is "
          "exactly what the driver's 10 s lap-clock tick does today.")
    print(f"  1a: writing COUNT 1..{ticks} to row 2, one per second, no bring-up.")
    print("      WATCH THE HOME SCREEN.")
    for n in range(1, ticks + 1):
        w.text(L.TGT_STATIONARY, 1, f"COUNT {n}")
        w.sleep(1.0)
    a.yn("1a", "H1-NONE",
         f"Did row 2 count up to {ticks} (not stuck on COUNT 0)?")

    w.why("H1-AFTER — set_text then bring-up. The predicted fix: the panel "
          "renders the field buffer only when re-summoned.")
    print(f"  1b: same counter, bring-up (0x7F) AFTER each write.")
    for n in range(1, ticks + 1):
        w.text(L.TGT_STATIONARY, 1, f"AFTER {n}")
        w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
        w.sleep(1.0)
    a.yn("1b", "H1-AFTER", f"Did row 2 count AFTER 1..{ticks}?")

    w.why("H1-BEFORE — bring-up then set_text. If THIS is the one that "
          "animates, the summon latches a frame and writes land in the NEXT "
          "one, and the driver's ordering has been backwards all along.")
    print(f"  1c: same counter, bring-up BEFORE each write.")
    for n in range(1, ticks + 1):
        w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
        w.text(L.TGT_STATIONARY, 1, f"BEFOR {n}")
        w.sleep(1.0)
    a.yn("1c", "H1-BEFORE", f"Did row 2 count BEFOR 1..{ticks}?")

    w.why("H1 control — re-send the whole configure+write+bring-up sequence, "
          "i.e. the full oled_home(). If ONLY this renders, 0x35 is "
          "write-once-per-configure and the fix is a whole re-config each tick.")
    for n in range(1, 4):
        w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
        w.text(L.TGT_STATIONARY, 0, "PROBE 1")
        w.text(L.TGT_STATIONARY, 1, f"FULL {n}")
        w.text(L.TGT_STATIONARY, 2, "ParVagues")
        w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
        w.sleep(1.0)
    a.yn("1d", "H1-RECONFIG", "Did row 2 count FULL 1..3?")


def _winning_pattern(results: list[dict]) -> str:
    """Which of 2a/2b/2c actually animated 0x36 — used to pick the rate test.

    Reading our own recorded answers instead of asking PLN to repeat himself.
    Falls back to 'after', the predicted pattern, if nothing answered yes.
    """
    got = {r["step"]: r["answer"] for r in results}
    for step, name in (("2a", "none"), ("2b", "after"), ("2c", "before")):
        if got.get(step) == "yes":
            return name
    return "after"


def step2_temporary(w: Wire, a: Asker, args) -> None:
    """H2 — same three patterns on 0x36, then how fast it can go.

    Symptom (b) lives here: DJF Hz only moves at the 0.9 s re-summon. If the
    bring-up is what renders, then the ceiling on smoothness is "how often can
    we bring up 0x36 before the panel or the SysEx parser complains" — and that
    number is what the driver's DJF path should be built around. Three rates,
    because "smooth" is PLN's word and only PLN can say it.
    """
    banner("STEP 2 — the touch overlay (temporary target 0x36)",
           """The DJF Hz readout refreshes once a second and PLN wants it smooth.
              Same three patterns first, then a rate ladder to find the ceiling.""")

    ticks = args.ticks
    w.why("H2 setup — bring 0x36 up the way oled_touch() does (arrangement 2, "
          "three fields: title / param / value)")
    w.config(L.TGT_TEMPORARY, 2)
    w.text(L.TGT_TEMPORARY, 0, "PROBE 2")
    w.text(L.TGT_TEMPORARY, 1, "COUNT 0")
    w.text(L.TGT_TEMPORARY, 2, "0x36")
    w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
    hold("The overlay should read PROBE 2 / COUNT 0 / 0x36. enter...", w.dry)

    w.why("H2-NONE — bare set_text between summons: precisely the driver's "
          "inter-summon path, and the suspect for 'not smooth at all'")
    for n in range(1, ticks + 1):
        w.text(L.TGT_TEMPORARY, 1, f"COUNT {n}")
        w.sleep(1.0)
    a.yn("2a", "H2-NONE",
         f"Did the overlay's row 2 count up to {ticks} with no bring-up?")

    w.why("H2-AFTER — set_text then bring-up")
    for n in range(1, ticks + 1):
        w.text(L.TGT_TEMPORARY, 1, f"AFTER {n}")
        w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
        w.sleep(1.0)
    a.yn("2b", "H2-AFTER", f"Did it count AFTER 1..{ticks}?")

    w.why("H2-BEFORE — bring-up then set_text")
    for n in range(1, ticks + 1):
        w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
        w.text(L.TGT_TEMPORARY, 1, f"BEFOR {n}")
        w.sleep(1.0)
    a.yn("2c", "H2-BEFORE", f"Did it count BEFOR 1..{ticks}?")

    # --- the rate ladder -----------------------------------------------------
    pattern = _winning_pattern(a.results)
    print()
    print(f"  rate ladder using the '{pattern}' pattern "
          f"({'from your answers above' if not w.dry else 'dry-run default'}).")
    print("  Each rate sweeps a fake Hz readout so you can judge MOTION, not")
    print("  just change — a smooth sweep and a laggy one look identical if the")
    print("  numbers are random. Values follow the real djf_readout shape.")

    for hz_rate, secs, label in ((20.0, args.rate_secs, "~20/s"),
                                 (10.0, args.rate_secs, "~10/s"),
                                 (5.0, args.rate_secs, "~5/s")):
        step = f"2-{int(hz_rate)}"
        w.why(f"H2-RATE {label} — is this cadence smooth, flickery, or laggy? "
              f"({int(hz_rate)} bring-ups/s = {int(hz_rate)} config + "
              f"{int(hz_rate)} text SysEx per second)")
        print()
        print(f"  {label}: sweeping for {secs:g}s. WATCH THE OVERLAY.")
        n_frames = max(1, int(hz_rate * secs))
        for i in range(n_frames):
            # A believable DJF readout: LPF sweeping 20 Hz -> 20 kHz and back,
            # the same prose lcxl3_language.djf_readout() produces.
            frac = abs(((i / max(1, n_frames - 1)) * 2.0) - 1.0)
            khz = 0.02 + frac * 19.98
            val = f"LPF {khz:.2f}kHz" if khz >= 1 else f"LPF {khz * 1000:.0f}Hz"
            if pattern == "before":
                w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
                w.text(L.TGT_TEMPORARY, 1, val)
            elif pattern == "after":
                w.text(L.TGT_TEMPORARY, 1, val)
                w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
            else:
                w.text(L.TGT_TEMPORARY, 1, val)
            w.sleep(1.0 / hz_rate)
        a.text(step, f"H2-RATE {label}",
               f"At {label}: smooth / flickery / laggy / blank / garbled? "
               "(one line, your words)")

    w.why("H2 teardown — dismiss our overlay so it does not sit on the panel")
    w.config(L.TGT_TEMPORARY, L.CFG_CANCEL)


def step3_percontrol(w: Wire, a: Asker, args) -> None:
    """H3 — can we repaint a per-control target ourselves, and which auto bit
    is doing the suppression work?

    Round 4 concluded "stop fighting, start labelling" and that was right, but
    it left the per-control targets as something only the FIRMWARE can bring up.
    If we can summon one ourselves, a lot becomes possible (a knob's screen can
    show a value we computed rather than the wire value). And the driver clears
    bits 5 AND 6 on the three DJF knobs on the theory that both matter; if only
    one does, we are suppressing a display we could have kept.
    """
    idx = args.control
    name = L.control_name(idx)
    banner(f"STEP 3 — one control's own display (target 0x{idx:02X}, {name})",
           f"""Every fader and encoder owns a display target addressed by its own
              DAW-mode CC index. Steps here ask you to TURN {name} — chosen
              because row B is fx-per-orbit, not one of Ardour's faders or
              levels, so a nudge is the least consequential thing on the
              surface. Turn it gently and only when asked.""")

    w.why(f"H3-IDLE — arrangement 2 with BOTH auto bits CLEAR, three fields, "
          f"no bring-up, control untouched. Does a per-control target render "
          f"purely because we wrote to it?")
    w.config(idx, 2)
    w.text(idx, 0, "PROBE3 A")
    w.text(idx, 1, "IDLE NOBRINGUP")
    w.text(idx, 2, "row3")
    w.sleep(1.5)
    a.yn("3a", "H3-IDLE",
         f"WITHOUT touching {name}: did the screen show PROBE3 A / IDLE...?")

    w.why("H3-IDLE+BRINGUP — same, then a bring-up. If this renders, we can "
          "drive a knob's own screen on our schedule, untouched.")
    w.text(idx, 1, "IDLE BRINGUP")
    w.config(idx, L.CFG_BRING_UP)
    w.sleep(1.5)
    a.yn("3b", "H3-IDLE-BRINGUP",
         f"Still not touching {name}: did it show IDLE BRINGUP?")

    w.why("H3-BIT6 — bit 6 only (auto-display on CHANGE). Turning the control "
          "should summon its screen; the question is whether OUR text is what "
          "comes up, or the firmware's own arrangement.")
    w.config(idx, 2 | L.CFG_AUTO_CHANGE)
    w.text(idx, 0, "PROBE3 B")
    w.text(idx, 1, "BIT6 CHANGE")
    w.text(idx, 2, "row3")
    hold(f"Now TURN {name} a little, read the screen, then press enter...", w.dry)
    a.yn("3c", "H3-BIT6",
         "On turning it, did the screen show PROBE3 B / BIT6 CHANGE "
         "(rather than a bare number)?")

    w.why("H3-BIT5 — bit 5 only (auto-display on TOUCH). The guide glosses "
          "touch as 'this is the Shift + rotate', so that is the gesture.")
    w.config(idx, 2 | L.CFG_AUTO_TOUCH)
    w.text(idx, 0, "PROBE3 C")
    w.text(idx, 1, "BIT5 TOUCH")
    w.text(idx, 2, "row3")
    hold(f"Now hold SHIFT and rotate {name}, then press enter...", w.dry)
    a.yn("3d", "H3-BIT5",
         "On Shift+rotate, did the screen show PROBE3 C / BIT5 TOUCH?")

    w.why("H3-SUPPRESS — both bits CLEAR. This is the state oled_labels() "
          "puts the three DJF knobs in. Does the firmware really stay quiet?")
    w.config(idx, 2)
    hold(f"Turn {name} again (and try Shift+rotate), then press enter...", w.dry)
    a.yn("3e", "H3-SUPPRESS",
         f"With both bits clear, did the panel stay QUIET when you moved {name} "
         "(no screen summoned at all)?")


def _arrangement_list(args) -> list[int]:
    """Which arrangement values to census.

    Honest about the limits: the config byte's bits 0-4 hold 0..31, 0 is
    documented as cancel and 31 as trigger, so 1..30 are the candidate
    arrangements. The guide's table documents 1, 2, 3 and 4. Nothing tells us
    whether 5..30 are real, aliases, or ignored — and "ignored" is the nasty
    case, because an ignored write leaves the PREVIOUS arrangement on screen and
    looks exactly like "this arrangement renders the same as the last one". The
    sentinel in the census step exists to make that case visible.
    """
    if args.arrangements:
        out: list[int] = []
        for part in args.arrangements.split(","):
            part = part.strip()
            if "-" in part:
                lo, hi = part.split("-", 1)
                out.extend(range(int(lo), int(hi) + 1))
            elif part:
                out.append(int(part))
        return [n for n in out if 1 <= n <= 30]
    if args.full_census:
        return list(range(1, 31))
    return [1, 2, 3, 4, 5, 6, 7, 8]


def step4_census(w: Wire, a: Asker, args) -> None:
    """H4 — the arrangement census. The highest-value question in the file.

    For each candidate arrangement: set it, write distinguishable strings F0..Fn
    into every field, bring it up, and have PLN describe the LAYOUT. `F0`..`F8`
    are deliberately the field INDICES, so "top line says F1" is a decoded
    answer, not a guess.

    THE SENTINEL. Before each candidate we set a known arrangement (1) with a
    loud, unmistakable string. If the candidate value is ignored by the
    firmware, PLN sees arrangement 1's layout still holding the sentinel text —
    which is distinguishable from a real arrangement, because a real one gets
    the F0..Fn strings written over it. Without this, "ignored" and "renders
    identically to the previous one" are the same observation and the census
    would quietly invent arrangements that do not exist.
    """
    idx = args.control
    name = L.control_name(idx)
    arrs = _arrangement_list(args)
    fields = args.fields
    banner("STEP 4 — the ARRANGEMENT CENSUS",
           f"""Target 0x{idx:02X} ({name}). {len(arrs)} arrangement(s):
              {', '.join(str(n) for n in arrs)}.
              Fields written: F0..F{fields - 1} (the string IS its field index).

              Two things we are hunting:
                (a) an arrangement where WE supply the value string, so a knob
                    can read "37%" or "2.4kHz" instead of the firmware's raw
                    0-127 (which for a relative encoder never moves at all);
                (b) an arrangement that puts the NAME on the TOP line. Our
                    per-control line 1 renders empty today and you cannot see
                    row 3 from your chair, so an empty top row is the most
                    legible row on the panel, wasted.

              For each one, say: how many lines, which Fn is on which line,
              big or small text, and whether a firmware-drawn NUMBER is there.
              If the screen looks blank, nudge {name} — the auto bits are set.
              Answer 'q' at any point to stop the census early.""")

    for arr in arrs:
        if a.aborted:
            break
        # The sentinel must be a DIFFERENT arrangement from the candidate, or
        # it proves nothing — for arrangement 1 we sentinel with 4 instead.
        sent = 4 if arr == 1 else 1
        w.why(f"H4-ARR sentinel — arrangement {sent} with a loud marker, so "
              f"that an IGNORED arrangement {arr} is visible as 'still the "
              f"sentinel' rather than passing as a real layout")
        w.config(idx, sent | L.CFG_AUTO_BOTH)
        for f in range(fields):
            w.text(idx, f, "SENTINEL" if f == 0 else f"s{f}")
        w.config(idx, L.CFG_BRING_UP)
        w.sleep(0.4)

        w.why(f"H4-ARR {arr} — set arrangement {arr} (auto bits set), write "
              f"F0..F{fields - 1}, bring up, and READ IT")
        w.config(idx, (arr & 0x1F) | L.CFG_AUTO_BOTH)
        for f in range(fields):
            w.text(idx, f, f"F{f}")
        w.config(idx, L.CFG_BRING_UP)
        w.sleep(0.3)
        print()
        print(f"  --- arrangement {arr} ---")
        seen = a.text(f"4-arr{arr}", f"H4-ARR {arr}",
                      f"arrangement {arr}: what do you see? (lines top->bottom, "
                      "which Fn where, text size, 'SENTINEL' if unchanged)")
        if a.aborted:
            break
        if seen.strip().lower() in ("s", "skip"):
            continue
        a.yn(f"4-num{arr}", f"H4-ARR {arr} numeric",
             f"arrangement {arr}: is a firmware-drawn NUMBER (a bare 0-127) "
             "on the screen?")


def step5_trigger(w: Wire, a: Asker, args) -> None:
    """H5 — is 0x7F a bring-up, or arrangement 31 plus both auto bits?

    0x7F == 0b1111111: bits 0-4 = 0x1F = 31 = ARR_TRIGGER, bit 5 set, bit 6 set.
    lcxl3.py names it CFG_BRING_UP and quotes the guide's "bring up the display
    with its current contents", and the driver sends it to 0x36 constantly. But
    if the firmware reads the WHOLE byte, then any bring-up sent to a
    PER-CONTROL target also re-arms the two auto bits — silently undoing the
    suppression `oled_labels()` sets on the three DJF knobs, whose whole point
    is that the firmware must not draw a number over our "HPF 3.4kHz !!".

    Two sub-tests, both cheap:
      5a  trigger with the auto bits CLEAR (0x1F). If the panel comes up, the
          trigger works on its own and 0x7F's high bits are just noise we have
          been sending — or they are a bit-set side effect, which 5b catches.
      5b  send the real 0x7F, then move the control. If it now auto-summons
          where 3e said it stayed quiet, the bring-up re-armed the bits and the
          driver has a latent DJF regression it re-arms every 0.9 s.
    """
    idx = args.control
    name = L.control_name(idx)
    banner("STEP 5 — is a 'bring-up' secretly arrangement 31 + both auto bits?",
           """0x7F = arr 31 | bit5 | bit6, arithmetically. If the firmware reads
              the whole byte, every bring-up re-arms the auto-display bits we
              cleared — which would silently undo the DJF suppression.""")

    w.why("H5-TRIGGER — arrangement 2, auto bits CLEAR, then trigger with "
          "0x1F (arr 31, no auto bits). Does a bare trigger render?")
    w.config(idx, 2)
    w.text(idx, 0, "PROBE5 A")
    w.text(idx, 1, "TRIG 0x1F")
    w.text(idx, 2, "row3")
    w.config(idx, L.ARR_TRIGGER)
    w.sleep(1.5)
    a.yn("5a", "H5-TRIGGER",
         f"Without touching {name}: did the screen show PROBE5 A / TRIG 0x1F?")

    w.why("H5-REARM — now the real 0x7F, then move the control. If it summons, "
          "0x7F set bits 5+6 and every driver bring-up re-arms them.")
    w.text(idx, 1, "TRIG 0x7F")
    w.config(idx, L.CFG_BRING_UP)
    w.sleep(0.5)
    hold(f"Now turn {name} gently, then press enter...", w.dry)
    a.yn("5b", "H5-REARM",
         f"After the 0x7F, does turning {name} SUMMON a screen again "
         "(it stayed quiet in step 3e with the bits clear)?")


# ------------------------------------------------------------------ verdict ----


def verdict(results: list[dict]) -> list[str]:
    """One paragraph naming which hypothesis survived, in the shape the parent
    session needs: what to change in the driver, and where."""
    got = {r["step"]: r["answer"] for r in results}
    out: list[str] = []

    def y(step: str) -> bool:
        return got.get(step) == "yes"

    def n(step: str) -> bool:
        return got.get(step) == "no"

    ran = {r["step"] for r in results}

    # --- H1 -----------------------------------------------------------------
    if not ran & {"1a", "1b", "1c", "1d"}:
        h1 = ""
    elif y("1a"):
        h1 = ("H1-NONE survives: a bare set_text DOES repaint a visible 0x35. "
              "The driver's comment was right and the frozen lap clock is a "
              "different bug — look upstream at _home_detail() / track_started, "
              "or at whether the 10 s branch runs at all "
              "(lcxl3-driver.py:881-886).")
    elif n("1a") and y("1b"):
        h1 = ("H1-AFTER survives: a field write reaches the panel only when a "
              "bring-up FOLLOWS it. Both symptoms are the same one-line fix — "
              "add configure_display(TGT_STATIONARY, 0x7F) after the lap-clock "
              "set_text (lcxl3-driver.py:884), and stop gating the 0x36 "
              "bring-up on the 0.9 s summon timer in oled_touch().")
    elif n("1a") and n("1b") and y("1c"):
        h1 = ("H1-BEFORE survives: only a bring-up that PRECEDES the writes "
              "renders them — the summon latches a frame and writes land in the "
              "next one. oled_touch()'s ordering (config, texts, config) has "
              "been half-right by accident; the lap clock needs a bring-up "
              "before its set_text, and the DJF path needs its summon moved.")
    elif n("1a") and n("1b") and n("1c") and y("1d"):
        h1 = ("H1-RECONFIG survives: 0x35 renders only on a full "
              "configure+write+bring-up cycle. The stationary target is "
              "effectively write-once-per-configure, so the lap-clock tick has "
              "to call the whole oled_home(), not a single set_text.")
    elif n("1a") and n("1b") and n("1c") and n("1d"):
        h1 = ("H1-NEITHER: nothing animated 0x35. Every pattern we can send "
              "failed, so the stationary target is not the place for a live "
              "clock at all — next suspects are the display-timeout feature "
              "(FEAT_DISPLAY_TIMEOUT, which the driver sets to 1.2 s and which "
              "may be dismissing 0x35), or a per-target write rate limit.")
    else:
        h1 = ("H1 undecided (step 1 was partial) — the stationary target's "
              "repaint rule is still an open question.")
    if h1:
        out.append(h1)

    # --- H2 -----------------------------------------------------------------
    rates = [(r["hypothesis"], r["answer"]) for r in results
             if r["step"].startswith("2-")]
    if rates:
        out.append("DJF smoothness, in PLN's words: "
                   + "; ".join(f"{h.split()[-1]} -> {ans!r}" for h, ans in rates)
                   + ". Pick the fastest rate he did not call flickery or "
                     "garbled and make that the DJF readout's cadence.")
    if y("2a"):
        out.append("0x36 differs from 0x35: it repaints on a bare set_text, so "
                   "the DJF lag is the driver's own diff/summon gating, not the "
                   "protocol.")

    # --- H3 -----------------------------------------------------------------
    if y("3b") and n("3a"):
        out.append("H3-IDLE-BRINGUP: a per-control target CAN be driven while "
                   "its control is untouched, as long as we bring it up. That "
                   "unlocks host-computed values on a knob's own screen.")
    elif y("3a"):
        out.append("H3-IDLE: per-control targets repaint on a bare write, "
                   "untouched — the strongest possible result, and it means the "
                   "0x36 overlay is optional for knob feedback.")
    elif n("3a") and n("3b"):
        out.append("Per-control targets are firmware-summoned only; labelling "
                   "(round 4's fix) really is the whole available surface there.")
    if y("3c") and n("3d"):
        out.append("Only bit 6 (change) summons on this firmware — clearing "
                   "bit 5 as well on the DJF knobs is harmless but pointless.")
    if y("3d") and n("3c"):
        out.append("Only bit 5 (Shift+rotate) summons — bit 6 is not what has "
                   "been stealing the screen, and the round-4 story needs "
                   "amending.")
    if n("3e"):
        out.append("!! Clearing both bits did NOT silence the firmware: the DJF "
                   "suppression in oled_labels() does not actually work, and "
                   "any prose readout on those three knobs is still contested.")

    # --- H4 -----------------------------------------------------------------
    census = [r for r in results if r["step"].startswith("4-arr")
              and r["answer"] not in ("dry-run", "aborted", "", "skip")]
    if census:
        out.append(f"Arrangement census: {len(census)} value(s) described — see "
                   "the table. Read them for (a) any arrangement whose value "
                   "line carries OUR string rather than a firmware number, and "
                   "(b) whichever one puts the name on the TOP line; that pair "
                   "is the per-control layout the driver should switch to. Any "
                   "answer that still reads SENTINEL means the firmware IGNORED "
                   "that arrangement value — not a layout, an absence.")

    # --- H5 -----------------------------------------------------------------
    if y("5b") and y("3e"):
        out.append("!! H5-REARM confirmed: sending 0x7F re-arms the auto-display "
                   "bits. Every bring-up undoes a suppression, so anything that "
                   "clears bits 5/6 must re-clear them after each summon.")
    elif y("5a"):
        out.append("A bare trigger (0x1F, no auto bits) renders — so a bring-up "
                   "need not carry bits 5+6, and suppression can survive it.")

    return out


# --------------------------------------------------------------------- main ----


def driver_procs() -> list[tuple[int, str]]:
    """(pid, cmdline) for any running lcxl3-driver — never touched, only named."""
    try:
        out = subprocess.run(["pgrep", "-af", "lcxl3-driver"],
                             capture_output=True, text=True, timeout=5).stdout
    except (OSError, subprocess.SubprocessError):
        return []
    procs = []
    for line in out.splitlines():
        pid, _, cmd = line.partition(" ")
        if pid.isdigit() and "pgrep" not in cmd:
            procs.append((int(pid), cmd.strip()))
    return procs


def restore(w: Wire, args) -> None:
    """Hand the device back the way the driver expects to find it.

    Not politeness: the probe leaves per-control targets on odd arrangements
    with the auto bits cleared, and a knob that silently refuses to show a value
    is exactly the failure round 4 spent an evening on. This mirrors
    Driver.stop(), which calls native_overlay(True) for the same reason.

    DAW mode is deliberately left ON — it is what the driver enables at start,
    and turning it off here would blank the panel while PLN is still reading it.
    """
    print()
    print("restoring the device to the state the driver expects:")
    w.why("restore — dismiss our overlay, un-name the probe control, put every "
          "per-control target back to arrangement 4 with both auto bits set "
          "(what Driver.stop()'s native_overlay(True) does), and leave the home "
          "screen on arrangement 2")
    w.config(L.TGT_TEMPORARY, L.CFG_CANCEL)
    w.text(args.control, 1, "")
    w.text(args.control, 2, "")
    w.text(args.control, 0, "")
    cfg = L.display_config(DRIVER_CONTROL_ARRANGEMENT, True, True)
    for idx in range(L.TGT_CONTROL_FIRST, L.TGT_CONTROL_LAST + 1):
        w.config(idx, cfg)
        w.sleep(0.002)
    w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
    w.text(L.TGT_STATIONARY, 0, "PROBE DONE")
    w.text(L.TGT_STATIONARY, 1, "restart driver")
    w.text(L.TGT_STATIONARY, 2, "ParVagues")
    w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
    print("  32 per-control targets -> arrangement 4 + bits 5|6")
    print("  0x36 cancelled, 0x35 -> arrangement 2")
    print("  DAW mode left ON (the driver enables it anyway)")
    print("  the probe control's name field is blank until the driver's next")
    print("  oled_labels() pass rewrites it — restart the driver to be sure.")


def print_table(results: list[dict]) -> None:
    print()
    print("=" * 72)
    print("RESULT TABLE")
    print("=" * 72)
    w_step = max([4] + [len(r["step"]) for r in results])
    w_hyp = max([10] + [len(r["hypothesis"]) for r in results])
    print(f"{'step':<{w_step}}  {'hypothesis':<{w_hyp}}  answer")
    print(f"{'-' * w_step}  {'-' * w_hyp}  {'-' * 30}")
    for r in results:
        ans = r["answer"] or "(blank)"
        print(f"{r['step']:<{w_step}}  {r['hypothesis']:<{w_hyp}}  {ans}")
        if r["note"]:
            print(f"{'':<{w_step}}  {'':<{w_hyp}}  note: {r['note']}")


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        prog="lcxl3-probe-display",
        description=__doc__.splitlines()[0],
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Display/config SysEx only. This probe never emits a CC or a "
               "note — the surface's CCs feed a live rig and CC93 is gPanic.")
    p.add_argument("--dry-run", action="store_true",
                   help="print every byte sequence it WOULD send, with the "
                        "hypothesis each one tests, and ask nothing")
    p.add_argument("--assume-driver-stopped", action="store_true",
                   help="proceed even though lcxl3-driver is running (it will "
                        "fight you: it re-asserts paint every 2 s and re-writes "
                        "all 32 labels every 20 s)")
    p.add_argument("--control", type=lambda s: int(s, 0),
                   default=DEFAULT_CONTROL,
                   help=f"per-control display target for steps 3-5 "
                        f"(default 0x{DEFAULT_CONTROL:02X} = encoder B1, which "
                        "is not one of Ardour's controls)")
    p.add_argument("--only", default="",
                   help="run only these steps, e.g. --only 1 or --only 1,4")
    p.add_argument("--ticks", type=int, default=5,
                   help="counter length in steps 1 and 2 (default 5, 1/s)")
    p.add_argument("--rate-secs", type=float, default=3.0,
                   help="seconds per rung of the step-2 rate ladder")
    p.add_argument("--arrangements", default="",
                   help="census values, e.g. '1-8' or '1,2,3,4,9' "
                        "(default 1-8; bits 0-4 allow 1..30)")
    p.add_argument("--full-census", action="store_true",
                   help="census every candidate arrangement 1..30 — 30 "
                        "questions, be sure")
    p.add_argument("--fields", type=int, default=9,
                   help="how many text fields to write per arrangement "
                        "(default 9: arrangement 3 has 9, the rest fewer, and "
                        "out-of-range writes appear to be ignored)")
    p.add_argument("--out", default=DEFAULT_OUT,
                   help="JSON results path")
    args = p.parse_args(argv)

    if args.control < L.TGT_CONTROL_FIRST or args.control > L.TGT_CONTROL_LAST:
        p.error(f"--control must be 0x{L.TGT_CONTROL_FIRST:02X}.."
                f"0x{L.TGT_CONTROL_LAST:02X} (only faders and encoders own a "
                "per-control display target; buttons do not)")

    steps = {s.strip() for s in args.only.split(",") if s.strip()} or \
        {"1", "2", "3", "4", "5"}

    print(__doc__.strip().splitlines()[0])
    print()

    procs = driver_procs()
    if procs and not args.dry_run and not args.assume_driver_stopped:
        print("!! lcxl3-driver is RUNNING. It will fight this probe:")
        print("   - reassert_paint() every 2 s")
        print("   - oled_labels() rewrites all 32 per-control targets every 20 s")
        print("   - oled_touch() redraws 0x36 on any surface movement")
        print("   Any 'no' you report could be the driver overwriting us.")
        print()
        for pid, cmd in procs:
            print(f"   pid {pid}: {cmd}")
        print()
        print("   To stop it (SIGTERM — it restores the device on the way out):")
        for pid, _cmd in procs:
            print(f"       kill {pid}")
        print("   Then run this probe. To bring it back afterwards:")
        for _pid, cmd in procs:
            print(f"       cd {pathlib.Path(__file__).resolve().parent} && {cmd}")
        print()
        print("   Or pass --assume-driver-stopped to proceed anyway.")
        return 2
    if procs and args.assume_driver_stopped:
        print(f"(warning: {len(procs)} lcxl3-driver process(es) still running, "
              "proceeding as asked)")

    surface = None
    if not args.dry_run:
        surface = L.Surface("daw")
        _disarm(surface)
        print(f"output port: {surface.out_name!r} (our own; no aconnect "
              "changes, nothing pruned)")
    else:
        print("--dry-run: no port opened, no questions asked. "
              "Every byte below is what a real run would send.")

    w = Wire(surface, args.dry_run)
    results: list[dict] = []
    a = Asker(results, args.dry_run)

    started = _dt.datetime.now().isoformat(timespec="seconds")
    try:
        w.why("setup — DAW mode is the only mode that paints or draws")
        w.daw_mode(True)
        w.sleep(0.25)

        if "1" in steps and not a.aborted:
            step1_stationary(w, a, args)
        if "2" in steps and not a.aborted:
            step2_temporary(w, a, args)
        if "3" in steps and not a.aborted:
            step3_percontrol(w, a, args)
        if "4" in steps and not a.aborted:
            step4_census(w, a, args)
        if "5" in steps and not a.aborted:
            step5_trigger(w, a, args)
    except KeyboardInterrupt:
        print("\n\ninterrupted — restoring the device before exit")
    finally:
        try:
            restore(w, args)
        except Exception as exc:            # noqa: BLE001
            print(f"  !! restore failed: {exc}")
            print("  Restart the driver; its startup re-configures everything.")
        if surface is not None:
            surface.close()

    if results:
        print_table(results)

    print()
    print("=" * 72)
    print("VERDICT")
    print("=" * 72)
    lines = verdict(results)
    if args.dry_run:
        print("  (dry run — no observations, so no hypothesis can be settled.)")
        print("  With real answers this section names the survivor and points at")
        print("  the exact driver lines to change.")
    for line in lines:
        print()
        for chunk in _wrap(line, 70):
            print(f"  {chunk}")

    out = pathlib.Path(args.out)
    try:
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps({
            "tool": "lcxl3-probe-display",
            "started": started,
            "finished": _dt.datetime.now().isoformat(timespec="seconds"),
            "argv": sys.argv[1:],
            "dry_run": args.dry_run,
            "driver_running": [{"pid": pid, "cmd": cmd} for pid, cmd in procs],
            "control_target": args.control,
            "control_name": L.control_name(args.control),
            "arrangements_probed": _arrangement_list(args),
            "aborted": a.aborted,
            "answers": results,
            "verdict": lines,
            "wire_log": w.log,
        }, indent=2) + "\n")
        print()
        print(f"answers + full wire log -> {out}")
    except OSError as exc:
        print(f"\n!! could not write {out}: {exc}")
        return 1
    return 0


def _wrap(text: str, width: int) -> list[str]:
    words, line, out = text.split(), "", []
    for word in words:
        if line and len(line) + 1 + len(word) > width:
            out.append(line)
            line = word
        else:
            line = f"{line} {word}".strip()
    if line:
        out.append(line)
    return out


if __name__ == "__main__":
    sys.exit(main())
