"""
check_traces.py - verify a twin run from the CSV traces the glued clients wrote.

    python check_traces.py <twin dir> [--traces <dir with *_trace.csv>] [--lag 3]
    python check_traces.py <twin dir> --selftest

For every consumed signal in <twin dir>/glue_wiring.json the consumer's column
must follow the producer's column: at each step the consumer value equals the
producer value at some step in [step-lag, step] (mod 256; lag 0 is a real delivery
order, not a bug), and the consumer must
have changed at least (steps / period) - lag times, where period is the producer's
BUS period from glue_wiring.json (older wiring files without one mean 1). Ethernet-only components have
nothing to check and are listed as such, never as passes.

--selftest proves the checker itself, no run needed: (1) compiles a tiny C++ program
against generic_stim.h and compares its produce() values with this file's Python
copy (the one duplicated formula, guarded); (2) fabricates traces from the wiring
with lag 1 and requires PASS; (3) corrupts one consumer column and requires FAIL.
"""

import argparse
import csv
import json
import os
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
GXX = r"D:\InnexisVSI\innexis_home\vsi_2026.2\common\mingw64\mingw64-11.2.0\bin\g++.exe"


def produce(step, k, period=1):
    return (k * 16 + step // period) & 0xFF


def read_trace(path):
    with open(path, encoding="utf-8", newline="") as fh:
        rows = list(csv.DictReader(fh))
    return {k: [int(r[k]) for r in rows] for k in (rows[0].keys() if rows else [])}


def check(twin, traces_dir, lag, period=1, quiet=False):
    wiring = json.load(open(os.path.join(twin, "glue_wiring.json"), encoding="utf-8"))
    results = {"pass": 0, "fail": 0, "missing": 0, "nothing": 0}
    lines = []
    cache = {}

    def trace_of(comp):
        if comp not in cache:
            p = os.path.join(traces_dir, comp + "_trace.csv")
            cache[comp] = read_trace(p) if os.path.isfile(p) else None
        return cache[comp]

    # period per produced signal: entries are [sig, k, period]; older wiring files carry
    # [sig, k] and mean period 1 (the pre-2026-09-08 single TWIN_PERIOD)
    period_of = {}
    for comp, r in wiring["components"].items():
        for entry in r["produce"]:
            period_of[(comp, entry[0])] = entry[2] if len(entry) > 2 else 1

    # producer rate: a producer at bus period p changes exactly (n-1)//p times over n
    # steps. This is what proves the declared period landed in the run, since the
    # link check below only compares consumer to producer and would pass either way.
    results["rate_ok"] = results["rate_bad"] = 0
    for (pc, ps), p in period_of.items():
        pt = trace_of(pc)
        if pt is None or ps not in pt:
            continue
        v = pt[ps]; n = len(v)
        ch = sum(1 for s in range(1, n) if v[s] != v[s - 1]); exp = (n - 1) // p
        results["rate_ok" if ch == exp else "rate_bad"] += 1
        if ch != exp:
            lines.append("RATE  {}.{}: {} change(s), period {} predicts {}".format(pc, ps, ch, p, exp))

    for comp, r in wiring["components"].items():
        if not r["consume"]:
            results["nothing"] += 1
            continue
        ct = trace_of(comp)
        if ct is None:
            results["missing"] += 1; lines.append("MISSING  {} (no trace)".format(comp)); continue
        for sig, pc, ps in r["consume"]:
            pt = trace_of(pc)
            if pt is None or ps not in pt or sig not in ct:
                results["missing"] += 1; lines.append("MISSING  {}.{} <- {}.{}".format(comp, sig, pc, ps)); continue
            cons, prod = ct[sig], pt[ps]
            n = min(len(cons), len(prod))
            bad = 0
            for s in range(lag, n):
                # [s-lag, s]: lag 0 is real. The fabric delivers within a step to any
                # component scheduled after the producer (WildfireDT 2026-09-08: the
                # VMS and Navigation Unit read navTime with no lag, the aggregator with 1).
                window = {prod[j] & 0xFF for j in range(max(0, s - lag), s + 1)}
                if (cons[s] & 0xFF) not in window:
                    bad += 1
            changes = sum(1 for s in range(1, n) if cons[s] != cons[s - 1])
            p = period_of.get((pc, ps), period)
            need = n // p - lag
            ok = bad == 0 and changes >= need
            results["pass" if ok else "fail"] += 1
            lines.append("{}  {}.{} <- {}.{}: {} step(s) off, {} change(s) (need >= {}, period {})".format(
                "ok  " if ok else "FAIL", comp, sig, pc, ps, bad, changes, need, p))
    if not quiet:
        for l in lines:
            print("  " + l)
    return results


def selftest(twin, lag):
    wiring = json.load(open(os.path.join(twin, "glue_wiring.json"), encoding="utf-8"))
    # 1. the formula: C++ against Python
    tmp = tempfile.mkdtemp()
    cxx = os.path.join(tmp, "p.cxx")
    with open(cxx, "w") as fh:
        fh.write('#include "generic_stim.h"\n#include <cstdio>\nint main(){long ps[]={1,2,3,5,12};for(int i=0;i<5;i++)for(long s=0;s<300;s++)for(int k=0;k<8;k++)std::printf("%d\\n",twin::produce(s,k,ps[i]));return 0;}\n')
    r = subprocess.run([GXX, "-std=c++11", "-static", "-I", HERE, "-o", os.path.join(tmp, "p.exe"), cxx], capture_output=True, text=True)
    if r.returncode:
        print("selftest: C++ compile failed:", r.stderr[:400]); return 1
    out = subprocess.run([os.path.join(tmp, "p.exe")], capture_output=True, text=True).stdout.split()
    want = [str(produce(s, k, p)) for p in (1, 2, 3, 5, 12) for s in range(300) for k in range(8)]
    print("selftest 1 formula C++ == Python over {} values (5 periods):".format(len(want)), "OK" if out == want else "MISMATCH")
    if out != want:
        return 1
    # 2. fabricate traces with lag 1, each producer at its own bus period
    tdir = os.path.join(tmp, "traces")
    os.makedirs(tdir)
    steps = 100
    values = {}
    for comp, rr in wiring["components"].items():
        for entry in rr["produce"]:
            sig, k = entry[0], entry[1]
            values[(comp, sig)] = [produce(s, k, entry[2] if len(entry) > 2 else 1) for s in range(steps)]
    for comp, rr in wiring["components"].items():
        cols = list(rr["trace"]) + ["{}_changes".format(s) for s, _, _ in rr["consume"]]
        cons = {sig: (pc, ps) for sig, pc, ps in rr["consume"]}
        with open(os.path.join(tdir, comp + "_trace.csv"), "w", newline="") as fh:
            w = csv.writer(fh); w.writerow(["step", "time_ns"] + cols)
            for s in range(steps):
                row = [s, s * 100000]
                for c in rr["trace"]:
                    if (comp, c) in values:
                        row.append(values[(comp, c)][s])
                    elif c in cons:
                        pc, ps = cons[c]
                        row.append(values[(pc, ps)][s - 1] if s >= 1 and (pc, ps) in values else 0)
                    else:
                        row.append(0)
                row += [0] * len(rr["consume"])
                w.writerow(row)
    res = check(twin, tdir, lag, quiet=True)
    print("selftest 2 fabricated run:", res, "->", "PASS" if res["fail"] == 0 and res["missing"] == 0 and res["pass"] > 0 else "FAIL")
    if res["fail"] or res["missing"] or not res["pass"]:
        return 1
    # 3. negative control: freeze one consumer column
    victim = next((c, rr["consume"][0][0]) for c, rr in wiring["components"].items() if rr["consume"])
    p = os.path.join(tdir, victim[0] + "_trace.csv")
    rows = list(csv.DictReader(open(p, encoding="utf-8", newline="")))
    for r_ in rows:
        r_[victim[1]] = "0"
    with open(p, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows)
    res2 = check(twin, tdir, lag, quiet=True)
    print("selftest 3 negative control ({}.{} frozen):".format(*victim), res2, "->", "FAIL DETECTED" if res2["fail"] >= 1 else "NOT DETECTED")
    if res2["fail"] < 1:
        return 1
    # 4. negative control for the rate check: a producer running at period 1 while its
    #    bus declares a slower period must be reported, even though every consumer
    #    still follows it and the link check alone would pass
    slow = next(((c, e[0], e[2]) for c, rr in wiring["components"].items() for e in rr["produce"] if len(e) > 2 and e[2] > 1), None)
    if slow is None:
        print("selftest 4 rate control: skipped, no bus slower than period 1 in this wiring")
        return 0
    pc, ps, p = slow
    pp = os.path.join(tdir, pc + "_trace.csv")
    rows = list(csv.DictReader(open(pp, encoding="utf-8", newline="")))
    k = next(e[1] for e in wiring["components"][pc]["produce"] if e[0] == ps)
    for i, r_ in enumerate(rows):
        r_[ps] = str(produce(i, k, 1))
    with open(pp, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows)
    res3 = check(twin, tdir, lag, quiet=True)
    print("selftest 4 rate control ({}.{} at period 1, declared {}):".format(pc, ps, p), res3, "->", "RATE FAIL DETECTED" if res3.get("rate_bad", 0) >= 1 else "NOT DETECTED")
    return 0 if res3.get("rate_bad", 0) >= 1 else 1


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("twin")
    ap.add_argument("--traces", help="directory holding the *_trace.csv files (default: the twin dir)")
    ap.add_argument("--lag", type=int, default=3)
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()
    if a.selftest:
        sys.exit(selftest(a.twin, a.lag))
    res = check(a.twin, a.traces or a.twin, a.lag)
    good = res["fail"] == 0 and res["missing"] == 0 and res["pass"] > 0 and res.get("rate_bad", 0) == 0
    print("RESULT:", res, "->", "PASS" if good else "FAIL")
    sys.exit(0 if good else 1)


if __name__ == "__main__":
    main()
