#!/usr/bin/env python3
"""Run the regression cases for the crm-audit scripts and compare each with its expectation.

Usage (SKILL_DIR is this skill's folder):
  python3 -I "$SKILL_DIR/scripts/check_fixture.py" "$SKILL_DIR/assets/regression-fixture.json"

"cases" are small inventories (with optional fields, automations, history and config) run through
audit_crm.py's validation, merge and rules, then compared with the expected status of each named rule,
an optional piece of finding, note or reason text, the expected verdicts, or an expected validation stop.
A case with "hubspot_flows" runs that raw v4 flow JSON through normalize_exports.py first. A case with
"extends" starts from the named case's inputs: inventory and config are merged key by key (a list
replaces), and fields, automations, history and hubspot_flows given in the case replace the base's.
"history_cases" are history exports (rows inside the fixture, or a file next to it) run through
writer_history.py and compared with the expected result. Run it after any change to the scripts.

Reads local files only and writes nothing. Exit code: 0 when every case matches, 1 when any differs,
2 on bad input.
"""
import argparse
import contextlib
import datetime as dt
import importlib.util
import io
import json
import os
import sys
from types import SimpleNamespace

INPUTS = ("inventory", "config", "fields", "automations", "history", "hubspot_flows", "as_of")

HERE = os.path.dirname(os.path.abspath(__file__))


def load_sibling(name):
    """Import a script from this folder by path; python3 -I keeps the script folder off sys.path.
    No bytecode cache is written, so the skill folder stays unchanged."""
    sys.dont_write_bytecode = True
    spec = importlib.util.spec_from_file_location(name, os.path.join(HERE, name + ".py"))
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def texts(r):
    return [t for _, t in r.items] + list(r.nc_reasons) + list(r.notes) + ([r.na] if r.na else [])


def with_base(ac, fx, case, seen=()):
    """The case's inputs, starting from the case it extends."""
    if not case.get("extends"):
        return case
    base = next((c for c in fx.get("cases") or [] if c.get("id") == case["extends"]), None)
    if base is None or case["extends"] in seen:
        raise ValueError("case %s extends %r, which is not an earlier case" % (case.get("id"), case["extends"]))
    base = with_base(ac, fx, base, seen + (case["extends"],))
    out = {k: v for k, v in base.items() if k in INPUTS}
    out.update({k: v for k, v in case.items() if k not in ("inventory", "config")})
    for k in ("inventory", "config"):
        out[k] = ac.deep_merge(base.get(k) or {}, case.get(k) or {})
    return out


def engine_case(ac, ne, fx, case):
    case = with_base(ac, fx, case)
    raw = json.loads(json.dumps(case.get("inventory") or {}))
    try:
        problems, _ = ac.validate_inventory(raw)
        if problems:
            raise ac.BadInput("; ".join(problems))
        cfg = ac.deep_merge(ac.deep_merge(ac.DEFAULTS, ac.DEFAULT_LISTS),
                            ac.deep_merge(fx.get("config") or {}, case.get("config") or {}))
        as_of = ac.parse_date(case.get("as_of") or fx.get("as_of")) or dt.date.today()
        autos = case.get("automations")
        if case.get("hubspot_flows") is not None:
            with contextlib.redirect_stderr(io.StringIO()):  # the adapter's notes are for people, not this check
                autos = list(autos or []) + ne.flows_from_data([("fixture", case["hubspot_flows"])])["automations"]
        inv = ac.build_inventory(raw, case.get("fields"), autos, case.get("history"))
        _, results, _ = ac.evaluate(inv, cfg, as_of)
    except ac.BadInput as e:
        want = case.get("expect_error")
        return [] if want and want in str(e) else ["stopped: %s" % e]
    if case.get("expect_error"):
        return ["expected the run to stop on %r, but it ran" % case["expect_error"]]
    out = []
    for e in case.get("expect") or []:
        got = results[e["rule"]].status()
        if got != e["status"]:
            out.append("%s expected %s, got %s" % (e["rule"], e["status"], got))
        elif e.get("contains") and not any(e["contains"] in t for t in texts(results[e["rule"]])):
            out.append("%s: no finding, note or reason mentions %r" % (e["rule"], e["contains"]))
    for k, want in (case.get("expect_verdict") or {}).items():
        got = ac.verdicts(results, inv)[k]["verdict"]
        if got != want:
            out.append("%s verdict expected %r, got %r" % (k, want, got))
    return out


def history_case(wh, fx_dir, case):
    fmt = case["format"]
    args = SimpleNamespace(format=fmt, field=case.get("field"), object=case.get("object"), record_id=case.get("record_id"),
                           group=case.get("group"))
    wmap = dict(case.get("writer_map") or {})
    if case.get("writer_map_file"):
        wmap.update(wh.load_writer_map(os.path.join(fx_dir, case["writer_map_file"])))
    th = dict(wh.DEFAULTS)
    as_of = dt.datetime.strptime(case.get("as_of") or "2026-11-01", "%Y-%m-%d").date()
    try:
        data = wh.load_input(os.path.join(fx_dir, case["file"]), fmt, args) if case.get("file") else case.get("rows")
        res = wh.run([data], args, wmap, th, as_of, int(case.get("window_days") or th["window_days"]))
    except wh.BadInput as e:
        return [] if case.get("expect_error") and case["expect_error"] in str(e) else ["stopped: %s" % e]
    if case.get("expect_error"):
        return ["expected the run to stop on %r, but it ran" % case["expect_error"]]
    exp = case.get("expect") or {}
    field = next((f for f in res["fields"] if not exp.get("field") or f["field"] == exp["field"]), {})
    out = ["%s expected %r, got %r" % (k, exp[k], field.get(k)) for k in
           ("status", "changes", "records_changed", "flip_flop_records", "automated_flip_flop_records", "human_reversal_records")
           if k in exp and field.get(k) != exp[k]]
    if exp.get("note_contains") and not any(exp["note_contains"] in n for n in res["notes"]):
        out.append("no note mentions %r" % exp["note_contains"])
    return out


def main():
    p = argparse.ArgumentParser(description="Regression cases for the crm-audit scripts.")
    p.add_argument("fixture")
    a = p.parse_args()
    try:
        with open(a.fixture, encoding="utf-8") as f:
            fx = json.load(f)
    except (OSError, ValueError) as e:
        print("check_fixture: cannot read %s: %s" % (a.fixture, e), file=sys.stderr)
        sys.exit(2)
    ac, wh, ne = load_sibling("audit_crm"), load_sibling("writer_history"), load_sibling("normalize_exports")
    fx_dir = os.path.dirname(os.path.abspath(a.fixture))
    ok_all, n_engine, n_history = True, 0, 0
    for case in fx.get("cases") or []:
        n_engine += 1
        try:
            problems = engine_case(ac, ne, fx, case)
        except ValueError as e:
            print("check_fixture: %s" % e, file=sys.stderr)
            sys.exit(2)
        exp = ", ".join("%s %s" % (e["rule"], e["status"]) for e in case.get("expect") or []) or "stops on " + str(case.get("expect_error"))
        ok_all = ok_all and not problems
        print("%s %s %s: %s%s" % ("ok  " if not problems else "MISMATCH", case.get("id"), case.get("title"), exp,
                                  ("; " + "; ".join(problems)) if problems else ""))
    for case in fx.get("history_cases") or []:
        n_history += 1
        problems = history_case(wh, fx_dir, case)
        exp = ", ".join("%s %s" % kv for kv in sorted((case.get("expect") or {}).items()))
        ok_all = ok_all and not problems
        print("%s %s %s: %s%s" % ("ok  " if not problems else "MISMATCH", case.get("id"), case.get("title"), exp,
                                  ("; " + "; ".join(problems)) if problems else ""))
    print("%d cases (%d engine, %d history), %s." % (n_engine + n_history, n_engine, n_history,
                                                     "all as expected" if ok_all else "some differ from their expectation"))
    sys.exit(0 if ok_all else 1)


if __name__ == "__main__":
    main()
