#!/usr/bin/env python3
"""Analyze fixture timing from one or more events.jsonl captures.

Pairs gameplay x0→x9→x1→0x0D phases, flags glued windows, and compares positions
when given multiple fixture data directories.

See ../PROTOCOL_TRANSACTIONS.md — hard Master Reply Timeout aligns with missing
0x0D close; MISSING x9 here is a soft capture signature only.

Usage:
  python3 analyze_fixture_timing.py data/
  python3 analyze_fixture_timing.py pos1/data pos2/data pos3/data pos4/data
  python3 analyze_fixture_timing.py --glob "4-Board Test Fixture/*/data"
"""

from __future__ import annotations

import argparse
import json
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from glob import glob
from pathlib import Path
from typing import Any, Optional

from pog422_fixture_core import REQUEST_TO_RESPONSE


def msg_is_request(msg: str) -> bool:
    try:
        v = int(msg, 16)
    except ValueError:
        return False
    return (v & 0x0F) == 0 and v in REQUEST_TO_RESPONSE


def msg_is_response(msg: str) -> bool:
    try:
        v = int(msg, 16)
    except ValueError:
        return False
    return (v & 0x0F) == 9


def msg_is_ack(msg: str) -> bool:
    try:
        v = int(msg, 16)
    except ValueError:
        return False
    return (v & 0x0F) == 1 and (v & 0xF0) in {m & 0xF0 for m in REQUEST_TO_RESPONSE}


def expected_response(msg: str) -> Optional[str]:
    try:
        v = int(msg, 16)
    except ValueError:
        return None
    r = REQUEST_TO_RESPONSE.get(v)
    return f"{r:02X}" if r is not None else None


@dataclass
class PacketEvent:
    t_perf_ns: int
    fixture_pos: int
    pi_label: str
    msg: str
    tid: Optional[str]
    game: Optional[str]
    seq: Optional[int]
    counter: Optional[int]
    len: Optional[int]
    packets_in_window: int = 1
    index_in_window: int = 0
    raw_line: Optional[int] = None


@dataclass
class RoundTrace:
    fixture_pos: int
    request: PacketEvent
    response: Optional[PacketEvent] = None
    ack: Optional[PacketEvent] = None
    statuses: list[PacketEvent] = field(default_factory=list)
    glued: bool = False
    notes: list[str] = field(default_factory=list)

    @property
    def req_to_resp_us(self) -> Optional[float]:
        if self.response is None:
            return None
        return (self.response.t_perf_ns - self.request.t_perf_ns) / 1000.0

    @property
    def resp_to_ack_us(self) -> Optional[float]:
        if self.response is None or self.ack is None:
            return None
        return (self.ack.t_perf_ns - self.response.t_perf_ns) / 1000.0

    @property
    def ack_to_first_status_us(self) -> Optional[float]:
        if self.ack is None or not self.statuses:
            return None
        return (self.statuses[0].t_perf_ns - self.ack.t_perf_ns) / 1000.0


def load_events(path: Path) -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    with path.open(encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if not line:
                continue
            events.append(json.loads(line))
    return events


def events_to_packets(events: list[dict[str, Any]]) -> list[PacketEvent]:
    packets: list[PacketEvent] = []
    for row in events:
        if row.get("ev") != "packet_complete":
            continue
        msg = row.get("msg")
        if not msg:
            continue
        packets.append(
            PacketEvent(
                t_perf_ns=int(row["t_perf_ns"]),
                fixture_pos=int(row.get("fixture_pos", 0)),
                pi_label=str(row.get("pi_label", "")),
                msg=str(msg),
                tid=row.get("tid"),
                game=row.get("game"),
                seq=row.get("seq"),
                counter=row.get("counter"),
                len=row.get("len"),
                packets_in_window=int(row.get("packets_in_window", 1)),
                index_in_window=int(row.get("index_in_window", 0)),
            )
        )
    packets.sort(key=lambda p: p.t_perf_ns)
    return packets


def build_round_traces(packets: list[PacketEvent]) -> list[RoundTrace]:
    traces: list[RoundTrace] = []
    i = 0
    while i < len(packets):
        pkt = packets[i]
        if not msg_is_request(pkt.msg):
            i += 1
            continue
        trace = RoundTrace(fixture_pos=pkt.fixture_pos, request=pkt)
        if pkt.packets_in_window > 1:
            trace.glued = True
            trace.notes.append(f"request in glued window ({pkt.packets_in_window} pkts)")

        j = i + 1
        while j < len(packets):
            nxt = packets[j]
            if msg_is_response(nxt.msg):
                exp = expected_response(pkt.msg)
                if exp and nxt.msg.upper() != exp:
                    trace.notes.append(f"unexpected resp {nxt.msg} expected {exp}")
                trace.response = nxt
                if nxt.packets_in_window > 1:
                    trace.glued = True
                    trace.notes.append(f"response in glued window ({nxt.packets_in_window} pkts)")
                j += 1
                continue
            if trace.response and msg_is_ack(nxt.msg):
                trace.ack = nxt
                j += 1
                continue
            if nxt.msg == "0D":
                trace.statuses.append(nxt)
                j += 1
                continue
            if trace.statuses or (trace.ack and not msg_is_request(nxt.msg)):
                break
            if msg_is_request(nxt.msg):
                trace.notes.append("missing response/ack before next request")
                break
            j += 1

        if trace.response is None:
            trace.notes.append("MISSING x9 (soft — slave may retry; not hard timeout)")
        if trace.ack and not trace.statuses:
            trace.notes.append("MISSING 0x0D close (hard Master Reply Timeout signature)")
        traces.append(trace)
        i = j if j > i else i + 1
    return traces


def summarize_traces(label: str, traces: list[RoundTrace]) -> None:
    print(f"\n=== {label} ===")
    if not traces:
        print("  (no request rounds found)")
        return

    glued = sum(1 for t in traces if t.glued)
    missing = sum(1 for t in traces if t.response is None)
    print(f"  rounds={len(traces)} glued={glued} missing_x9={missing}")

    for t in traces:
        req = t.request
        resp_msg = t.response.msg if t.response else "--"
        ack_msg = t.ack.msg if t.ack else "--"
        status_n = len(t.statuses)
        if t.statuses and t.statuses[0].counter is not None:
            ctr = t.statuses[0].counter
            status_ctr = f"0x{ctr:02X}" if isinstance(ctr, int) else str(ctr)
        else:
            status_ctr = "--"
        r_us = t.req_to_resp_us
        a_us = t.resp_to_ack_us
        s_us = t.ack_to_first_status_us
        r_str = f"{r_us:.1f} us" if r_us is not None else "n/a"
        a_str = f"{a_us:.1f} us" if a_us is not None else "n/a"
        s_str = f"{s_us:.1f} us" if s_us is not None else "n/a"
        flags = " GLUED" if t.glued else ""
        note = f"  ({'; '.join(t.notes)})" if t.notes else ""
        print(
            f"  req=0x{req.msg} tid={req.tid} game={req.game} "
            f"→ resp=0x{resp_msg} ({r_str}) ack=0x{ack_msg} ({a_str}) "
            f"status=0x0D×{status_n} ctr={status_ctr} first_after_ack={s_str}{flags}{note}"
        )

    resp_times = [t.req_to_resp_us for t in traces if t.req_to_resp_us is not None]
    if resp_times:
        resp_ms = [x / 1000.0 for x in resp_times]
        print(
            f"  x0→x9: min={min(resp_ms):.2f} ms  "
            f"max={max(resp_ms):.2f} ms  avg={sum(resp_ms)/len(resp_ms):.2f} ms"
        )


def count_event_types(events: list[dict[str, Any]]) -> dict[str, int]:
    counts: dict[str, int] = defaultdict(int)
    for row in events:
        counts[str(row.get("ev", "?"))] += 1
    return dict(counts)


def summarize_events(label: str, events_path: Path) -> None:
    events = load_events(events_path)
    counts = count_event_types(events)
    glued = sum(1 for e in events if e.get("ev") == "window_glued")
    discarded = sum(
        e.get("count", 0) for e in events if e.get("ev") == "bytes_discarded_inactive"
    )
    print(f"\n--- Events: {label} ({events_path}) ---")
    print(f"  total_events={len(events)} window_glued={glued} inactive_bytes={discarded}")
    for key in sorted(counts):
        print(f"  {key}: {counts[key]}")


def detect_witness_gaps(events: list[dict[str, Any]], label: str) -> None:
    """Flag bytes_discarded_inactive events near MRT-related windows."""
    discards: list[dict[str, Any]] = []
    mrt_events: list[dict[str, Any]] = []
    for ev in events:
        kind = ev.get("ev", "")
        if kind == "bytes_discarded_inactive":
            discards.append(ev)
        elif kind in ("x9_no_local_x1", "mrt_x9_inject_diag", "mrt_risk_arm_skip"):
            mrt_events.append(ev)
    if not discards:
        return
    total_discarded = sum(d.get("count", 0) for d in discards)
    print(f"\n  ** WITNESS GAP ALERT ({label}): {len(discards)} discard events, {total_discarded} bytes dropped")
    for d in discards[:10]:
        t_ns = d.get("t_perf_ns", 0)
        print(f"     t={t_ns} count={d.get('count',0)} first_byte={d.get('first_byte','??')}")
    if len(discards) > 10:
        print(f"     ... and {len(discards) - 10} more")
    if mrt_events:
        print(f"  ** {len(mrt_events)} MRT-related events in same capture — gaps may hide ACK traffic")


def find_events_file(path: Path) -> Optional[Path]:
    if path.is_file() and path.name == "events.jsonl":
        return path
    candidate = path / "events.jsonl"
    if candidate.is_file():
        return candidate
    return None


def main() -> int:
    parser = argparse.ArgumentParser(description="Analyze POG422 fixture timing logs.")
    parser.add_argument(
        "paths",
        nargs="*",
        help="data/ directories or events.jsonl files",
    )
    parser.add_argument(
        "--glob",
        dest="glob_pattern",
        default="",
        help='Find events.jsonl under glob, e.g. "4-Board Test Fixture/*/topbox*/data"',
    )
    args = parser.parse_args()

    paths: list[Path] = [Path(p) for p in args.paths]
    if args.glob_pattern:
        for match in glob(args.glob_pattern, recursive=True):
            ep = find_events_file(Path(match))
            if ep:
                paths.append(ep.parent if ep.name == "events.jsonl" else ep)

    if not paths:
        default = Path("data")
        if find_events_file(default):
            paths = [default]
        else:
            print("No events.jsonl found. Pass data/ paths or --glob.", file=sys.stderr)
            return 1

    seen: set[Path] = set()
    for raw in paths:
        events_path = find_events_file(raw)
        if not events_path or events_path in seen:
            continue
        seen.add(events_path)

        events = load_events(events_path)
        packets = events_to_packets(events)
        pos = packets[0].fixture_pos if packets else events[0].get("fixture_pos", "?")
        label = f"pos{pos} ({events_path.parent})"
        summarize_events(label, events_path)
        detect_witness_gaps(events, label)
        traces = build_round_traces(packets)
        summarize_traces(label, traces)

    if len(seen) >= 2:
        print("\n=== Cross-position note ===")
        print(
            "  perf_counter is per-Pi — use 0x0D session counter + msg/tid/game to align rounds,"
        )
        print("  or correlate by identical packet hex on the multi-drop trunk (pos 1 vs 3).")

    return 0


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