#!/usr/bin/env python3
"""Analyze GDR packet data-in DF70 bursts against the configured payload.

v120 companion for the MN103S GDR-8050L auth harness.  It reconstructs the
payload stream written to DF70.  The firmware emits a small leading prelude
burst and then repeats those bytes at the start of the first full burst; v120
deduplicates that prelude so full 2048-byte PFI/DMI payloads can verify exactly.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
from pathlib import Path
from typing import Any


def parse_hex_stream(text: str | None) -> list[int]:
    if not text:
        return []
    out: list[int] = []
    for tok in str(text).replace(",", " ").split():
        if tok == "...":
            break
        try:
            out.append(int(tok, 16) & 0xFF)
        except Exception:
            pass
    return out


def load_expected_from_config(config_path: Path) -> tuple[list[int], dict[str, Any]]:
    cfg = json.loads(config_path.read_text(encoding="utf-8"))
    meta = dict(cfg.get("packet_data_in") or {})
    expected = parse_hex_stream(meta.get("full_hex"))
    if expected:
        return expected, meta

    # Fallback for older configs: reconstruct from packet_data_in read overrides.
    ros = ((cfg.get("trace") or {}).get("read_overrides") or [])
    static: dict[int, int] = {}
    lanes: dict[int, list[int]] = {}
    source_addr = int(str(meta.get("source_addr", "0x1C8")), 0)
    chunk_size = int(meta.get("source_chunk_size", 8) or 8)
    prelude_len = int(meta.get("source_prelude_len", 2) or 2)
    for r in ros:
        name = str(r.get("name", ""))
        addr = int(str(r.get("address", "0")), 0)
        vals = [int(str(v), 0) & 0xFF for v in r.get("values", [])]
        if name.startswith("packet_data_in_chunked_"):
            lanes[addr - source_addr] = vals
        elif name.startswith("packet_data_in_") and vals:
            try:
                idx = int(name.rsplit("_", 1)[1], 16)
            except Exception:
                idx = addr - source_addr
            static[idx] = vals[0]
    if static:
        return [static[i] for i in range(max(static) + 1) if i in static], meta
    if lanes:
        length = int(meta.get("length", 0) or 0)
        out: list[int] = []
        chunk = 0
        while not length or len(out) < length:
            any_added = False
            for lane in range(chunk_size):
                vals = lanes.get(lane) or []
                idx = chunk + (1 if lane < prelude_len else 0)
                if idx < len(vals):
                    out.append(vals[idx])
                    any_added = True
                    if length and len(out) >= length:
                        break
            if not any_added:
                break
            chunk += 1
        return out, meta
    return [], meta


def _row_addr(row: dict[str, str]) -> int | None:
    for key in ("addr", "address"):
        val = row.get(key)
        if val:
            try:
                return int(val, 0)
            except Exception:
                pass
    name = row.get("name", "")
    if name.startswith("watch_"):
        try:
            return int(name.split("_")[1], 16)
        except Exception:
            return None
    return None


def _row_val(row: dict[str, str]) -> int | None:
    b = row.get("bytes") or row.get("bytes_hex") or ""
    if b:
        try:
            return int(b.split()[0], 16) & 0xFF
        except Exception:
            pass
    v = row.get("value")
    if v:
        try:
            return int(v, 0) & 0xFF
        except Exception:
            pass
    return None


def collect_df70_bursts(trace_path: Path, base: int = 0xDF70, window: int = 0x40) -> list[list[int]]:
    writes: list[tuple[int, int, int]] = []
    with trace_path.open(newline="", encoding="utf-8", errors="replace") as f:
        for row in csv.DictReader(f):
            if row.get("event") != "watch_write":
                continue
            addr = _row_addr(row)
            if addr is None or not (base <= addr < base + window):
                continue
            val = _row_val(row)
            if val is None:
                continue
            try:
                cyc = int(row.get("cycle") or 0)
            except Exception:
                cyc = 0
            writes.append((cyc, addr, val))

    bursts: list[list[int]] = []
    cur: list[int] = []
    last_cyc: int | None = None
    last_addr: int | None = None
    for cyc, addr, val in writes:
        # Consecutive DF70 burst writes are tight and usually monotonic by addr.
        # A new burst normally has a much larger cycle gap or restarts at DF70.
        new_burst = False
        if last_cyc is not None:
            if cyc - last_cyc > 20:
                new_burst = True
            elif last_addr is not None and addr <= last_addr and cur:
                new_burst = True
        if new_burst:
            bursts.append(cur)
            cur = []
        cur.append(val)
        last_cyc = cyc
        last_addr = addr
    if cur:
        bursts.append(cur)
    return bursts


def sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Verify chunked GDR DF70 packet data-in stream against config payload")
    ap.add_argument("trace_csv")
    ap.add_argument("--config", required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--out-payload")
    ns = ap.parse_args(argv)

    trace_path = Path(ns.trace_csv)
    config_path = Path(ns.config)
    expected, meta = load_expected_from_config(config_path)
    bursts = collect_df70_bursts(trace_path)
    prelude_len = int(meta.get("source_prelude_len", 2) or 2)
    raw_prepend = meta.get("source_prepend_prelude", True)
    prepend_prelude = raw_prepend if isinstance(raw_prepend, bool) else str(raw_prepend).strip().lower() not in ("0", "false", "no", "off", "none")

    payload_bursts = bursts[:]
    reconstruction_mode = "raw"
    # Observed GDR packet data-in helper pattern:
    #   burst 0: first N bytes (usually a 2-byte length/header prelude)
    #   burst 1: the first full source window, repeating those same N bytes
    # For payloads shorter than the captured DF70 window, dropping burst 0 was
    # enough.  For exact 2048-byte sectors, that loses the final two bytes.
    # The correct stream reconstruction is therefore:
    #   burst0 + burst1[N:] + burst2 + ...
    if not prepend_prelude:
        actual_stream = [b for burst in payload_bursts for b in burst]
        reconstruction_mode = "raw_no_prelude"
    elif (
        len(payload_bursts) >= 2
        and expected
        and prelude_len > 0
        and len(payload_bursts[0]) == prelude_len
        and payload_bursts[0] == expected[:prelude_len]
        and payload_bursts[1][:prelude_len] == payload_bursts[0]
    ):
        actual_stream = list(payload_bursts[0]) + list(payload_bursts[1][prelude_len:])
        for burst in payload_bursts[2:]:
            actual_stream.extend(burst)
        reconstruction_mode = "prelude_dedup"
    elif payload_bursts and expected and len(payload_bursts[0]) == prelude_len and payload_bursts[0] == expected[:prelude_len]:
        payload_bursts = payload_bursts[1:]
        actual_stream = [b for burst in payload_bursts for b in burst]
        reconstruction_mode = "prelude_drop"
    else:
        actual_stream = [b for burst in payload_bursts for b in burst]
    actual_prefix = actual_stream[:len(expected)] if expected else actual_stream

    matched = 0
    for a, b in zip(actual_prefix, expected):
        if a != b:
            break
        matched += 1
    exact = bool(expected) and len(actual_prefix) >= len(expected) and actual_prefix[:len(expected)] == expected
    mismatch = None
    if expected and matched < len(expected):
        got = actual_prefix[matched] if matched < len(actual_prefix) else None
        exp = expected[matched]
        mismatch = (matched, got, exp)

    lines = []
    lines.append("# GDR chunked packet data-in verification")
    lines.append("")
    lines.append(f"trace={trace_path.name}")
    lines.append(f"config={config_path.name}")
    lines.append(f"source_mode={meta.get('source_mode', 'unknown')}")
    lines.append(f"expected_len={len(expected)}")
    lines.append(f"df70_burst_count={len(bursts)}")
    lines.append(f"df70_burst_lengths_first32={','.join(str(len(x)) for x in bursts[:32])}")
    lines.append(f"reconstruction_mode={reconstruction_mode}")
    lines.append(f"actual_stream_len={len(actual_stream)}")
    lines.append(f"matched_prefix_len={matched}")
    lines.append(f"exact_complete_payload={str(exact).lower()}")
    if expected:
        lines.append(f"expected_sha256={sha256_hex(bytes(expected))}")
        lines.append(f"actual_prefix_sha256={sha256_hex(bytes(actual_prefix))}")
        lines.append(f"expected_first64={' '.join(f'{b:02X}' for b in expected[:64])}")
        lines.append(f"actual_first64={' '.join(f'{b:02X}' for b in actual_prefix[:64])}")
    if mismatch:
        off, got, exp = mismatch
        got_s = "EOF" if got is None else f"0x{got:02X}"
        lines.append(f"first_mismatch_offset={off} actual={got_s} expected=0x{exp:02X}")
    lines.append("")
    lines.append("## Burst samples")
    for i, burst in enumerate(bursts[:16]):
        lines.append(f"- {i}: len={len(burst)} bytes={' '.join(f'{b:02X}' for b in burst[:32])}")

    Path(ns.out).write_text("\n".join(lines) + "\n", encoding="utf-8")
    if ns.out_payload:
        Path(ns.out_payload).write_bytes(bytes(actual_prefix))
    print(f"[OK] wrote {ns.out}")
    if ns.out_payload:
        print(f"[OK] wrote {ns.out_payload}")
    return 0 if exact or not expected else 1


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