#!/usr/bin/env python3
"""Read one settle-lag poll file and say what it shows.

**This is not the procedure.** `rule.py` is the procedure, and it was sealed
before any data existed, so that the definition of "settle lag" could not be
adjusted to suit the number that came out. This file only reads what `rule.py`
wrote. It is deliberately separate and deliberately unsealed: a reader can be
improved after the fact without touching what was promised.

It recomputes t1 and t2 from the rows rather than trusting the summary line,
for the same reason — the summary is the runner's claim and the rows are the
observation. If the two disagree, that disagreement is printed, not smoothed.

Usage:
    python3 research/hesper-settle-lag/report.py poll-2026-09-23-2000.jsonl
"""

import json
import sys
from pathlib import Path


def load(path):
    """(rows, summary). A truncated final line is dropped and reported."""
    rows, summary, broken = [], None, 0
    for line in Path(path).read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            item = json.loads(line)
        except ValueError:
            broken += 1
            continue
        if "summary" in item:
            summary = item["summary"]
        else:
            rows.append(item)
    return rows, summary, broken


def moments(rows):
    """t1 and t2 recomputed from the rows, as (seconds, row) or (None, None).

    t1: the first reading at or after the boundary that is not a plain 200.
    t2: the first reading whose `turn` differs from the last turn seen before
        the boundary. Both are bracketed by the poll interval — the true moment
        lies between the named reading and the one before it.
    """
    before = [r for r in rows if (r.get("since_boundary_s") or 0) < 0]
    turn_before = next((r["turn"] for r in reversed(before) if r.get("turn")), None)

    t1 = next((r for r in rows
               if r.get("since_boundary_s", -1) >= 0 and r.get("status") != 200), None)
    t2 = next((r for r in rows
               if r.get("turn") is not None and turn_before is not None
               and r["turn"] != turn_before), None)
    return turn_before, t1, t2


def main():
    if len(sys.argv) != 2:
        print(__doc__)
        return 2
    path = sys.argv[1]
    rows, summary, broken = load(path)
    if not rows:
        print(f"{path}: no readings")
        return 1

    turn_before, t1, t2 = moments(rows)
    span = (rows[0].get("since_boundary_s"), rows[-1].get("since_boundary_s"))
    statuses = {}
    for r in rows:
        statuses[r.get("status")] = statuses.get(r.get("status"), 0) + 1

    print(f"file      {path}")
    print(f"readings  {len(rows)}  from {span[0]:+.1f}s to {span[1]:+.1f}s"
          f"{f'  ({broken} unparseable line{chr(115) * (broken != 1)})' if broken else ''}")
    print(f"statuses  " + ", ".join(f"{k}x{v}" for k, v in sorted(
        statuses.items(), key=lambda kv: (kv[0] is None, kv[0]))))
    print(f"turn before the boundary  {turn_before}")

    if t1 is None:
        print("t1        NOT SEEN — no non-200 at or after the boundary. Either "
              "the settle never took the write lock for a whole second, or the "
              "poll missed the window. One second is the resolution; a settle "
              "shorter than that is invisible to this method.")
    else:
        print(f"t1        +{t1['since_boundary_s']:.3f}s  "
              f"status {t1['status']} {t1.get('code')}   (settle took the lock)")
    if t2 is None:
        print("t2        NOT SEEN — the turn did not move inside the window.")
    else:
        print(f"t2        +{t2['since_boundary_s']:.3f}s  "
              f"turn {turn_before} -> {t2['turn']}   (a poller can first know)")
    if t1 is not None and t2 is not None:
        print(f"settle    t2-t1 = {t2['since_boundary_s'] - t1['since_boundary_s']:.3f}s "
              f"(the settle itself)   t1-t0 = {t1['since_boundary_s']:.3f}s (the timer)")

    # The cursor is the other half of the answer: it moves whenever any event
    # is written, and the settle writes many. A cursor that keeps moving after
    # t2 means the turn number is published before the world has stopped.
    after = [r for r in rows if t2 is not None
             and r.get("since_boundary_s", 0) >= t2["since_boundary_s"]]
    cursors = [r["cursor"] for r in after if r.get("cursor")]
    if cursors:
        distinct = []
        for c in cursors:
            if not distinct or distinct[-1] != c:
                distinct.append(c)
        print(f"cursor    {len(distinct)} distinct value(s) at or after t2"
              + ("  — it kept moving after the turn was published"
                 if len(distinct) > 1 else "  — settled by t2"))

    if summary:
        claimed = (summary.get("t1_first_not_200_s"), summary.get("t2_turn_moved_s"))
        mine = (None if t1 is None else t1["since_boundary_s"],
                None if t2 is None else t2["since_boundary_s"])
        if claimed != mine:
            print(f"MISMATCH  the run's own summary says {claimed}, the rows say "
                  f"{mine}. Trust the rows and say so in the write-up.")
    else:
        print("no summary line — the run did not reach its end (killed, or still "
              "going). The readings above are still what was observed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
