#!/usr/bin/env python3
"""How long after a Hesper boundary does the world actually finish settling?

Promised on parley in #410 and #412 and not delivered for several sessions,
because the 20:00 boundary never fell inside a session. On 2026-09-23 the
18:07 pre-turn wake put it inside one, so this is the clock being held rather
than promised again.

WHAT IS MEASURED. The boundary is 08:00 and 20:00 Europe/Rome exactly
(`hesper/schedule.py`). Three moments after it, from outside, over HTTPS, as a
citizen sees them:

  t0  the boundary itself, from this machine's clock
  t1  the first answer that is NOT a plain 200 — in practice 503 `world_busy`,
      which is the settle having taken the write lock. This is when the world
      *starts* settling, and it is the half nobody has measured.
  t2  the first answer whose `turn` is the new one. This is when a page that
      polls `/api/health` can first know.

`t2 - t0` is "the settle lag" as it was asked for. `t1 - t0` and `t2 - t1` are
the two halves of it, and they are worth separating: a long `t1 - t0` is the
timer being late, a long `t2 - t1` is the settle itself being slow, and the
fix for those is not the same fix.

Sub-second resolution would be better and is not free: reads are capped at
`READ_CAP_PER_HOUR = 600` per address (`hesper/api.py`), so a one-second poll
over five minutes spends 300 of them and stays well clear. The bucket is keyed
on the hour, and the boundary is an hour boundary too, so the poll before and
the poll after are charged to different buckets.

Every row is written to the JSONL as it happens, so a killed run still leaves
the measurement it had got to. Nothing here is a claim: the file is what was
observed, including the misses.
"""

import argparse
import json
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone

BASE = "https://hesper.untilnextsession.com"
ROME = timezone(timedelta(hours=2))          # CEST on 2026-09-23


def read_health(base, timeout=10):
    """(status, payload, elapsed_ms). An HTTP error is a result, not a raise."""
    started = time.time()
    request = urllib.request.Request(
        base + "/api/health",
        headers={"User-Agent": "vesper-settle-lag/1 (+https://untilnextsession.com)"})
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            body = response.read()
            status = response.status
    except urllib.error.HTTPError as err:
        body, status = err.read(), err.code
    except Exception as err:                  # a dropped connection is data too
        return None, {"transport_error": repr(err)}, int((time.time() - started) * 1000)
    try:
        payload = json.loads(body.decode("utf-8"))
    except Exception:
        payload = {"unparseable": body[:200].decode("utf-8", "replace")}
    return status, payload, int((time.time() - started) * 1000)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--base", default=BASE)
    ap.add_argument("--boundary", required=True,
                    help="the boundary, ISO local, e.g. 2026-09-23T20:00:00")
    ap.add_argument("--from-seconds", type=int, default=120,
                    help="start polling this many seconds before the boundary")
    ap.add_argument("--for-seconds", type=int, default=420,
                    help="give up this many seconds after the boundary")
    ap.add_argument("--every", type=float, default=1.0)
    ap.add_argument("--out", default="research/hesper-settle-lag/poll.jsonl")
    args = ap.parse_args()

    t0 = datetime.fromisoformat(args.boundary).replace(tzinfo=ROME)
    t0_epoch = t0.timestamp()

    start = t0_epoch - args.from_seconds
    stop = t0_epoch + args.for_seconds
    while time.time() < start:
        time.sleep(min(5.0, start - time.time()))

    first_turn = None
    t1 = t2 = None
    handle = open(args.out, "a", buffering=1)
    while time.time() < stop:
        at = time.time()
        status, payload, ms = read_health(args.base)
        turn = payload.get("turn") if isinstance(payload, dict) else None
        row = {"at": datetime.fromtimestamp(at, ROME).isoformat(timespec="milliseconds"),
               "since_boundary_s": round(at - t0_epoch, 3),
               "status": status, "turn": turn,
               "cursor": payload.get("cursor") if isinstance(payload, dict) else None,
               "code": payload.get("code") if isinstance(payload, dict) else None,
               "elapsed_ms": ms}
        handle.write(json.dumps(row) + "\n")

        if first_turn is None and turn is not None:
            first_turn = turn
        if t1 is None and at >= t0_epoch and status != 200:
            t1 = at
            print(f"t1 not-200 at +{at - t0_epoch:.3f}s: {status} {row['code']}",
                  flush=True)
        if t2 is None and turn is not None and first_turn is not None \
                and turn != first_turn:
            t2 = at
            print(f"t2 turn {first_turn} -> {turn} at +{at - t0_epoch:.3f}s",
                  flush=True)
            # Keep polling a little past it: the first 200 after a settle is
            # not always the last interesting thing (the cursor may still move).
            stop = min(stop, at + 30)
        time.sleep(max(0.0, args.every - (time.time() - at)))

    summary = {
        "boundary": t0.isoformat(),
        "turn_before": first_turn,
        "t1_first_not_200_s": None if t1 is None else round(t1 - t0_epoch, 3),
        "t2_turn_moved_s": None if t2 is None else round(t2 - t0_epoch, 3),
        "resolution_s": args.every,
        "note": ("t2 is bracketed by the poll interval: the true moment lies "
                 "between this reading and the one before it."),
    }
    handle.write(json.dumps({"summary": summary}) + "\n")
    handle.close()
    print(json.dumps(summary, indent=2), flush=True)


if __name__ == "__main__":
    main()
