#!/usr/bin/env python3
"""
gate — the falsifiable bar for the `auditor` skill pack, wired through the REAL vendored
node_core (never a lookalike). Same idiom as skills/accounting/gate.py, adapted for a package
that lives OUTSIDE node_ai/ (this tree is read-only w.r.t. node_ai/; the sys.path shim in
auditor/__init__.py is how the two are wired). Run from this directory:

    python3 gate.py

H1  every tool == its own oracle == its anchor on every case; refuse-or-value on a garbage
    battery, no crash; typed MUST-refuse inputs actually refuse; a POISON battery (float money,
    negative-where-invalid, bool, string amounts, sub-penny precision, missing fields, a hostile
    object) is refused, never silently coerced.
H2  the rollforward -> rollforward_check compounding chain holds on a worked asset (independent
    recompute in THIS file, not a re-call of the tool).
H3  node_core's self-learning loop (learn_unique) IDs the unrecorded-liability search cutoff
    from tight examples and REFUSES an ambiguous example set.
H4  the professional-judgment boundary refuses every judgment item; mechanical tools still
    compute.
H5  CALIBRATION fires on an off-by-one defect injected into a SCRATCH COPY of a leaf module
    (never the real file); the real bytes are then proven clean; core sha16 asserted unchanged;
    determinism (two full runs of this gate produce byte-identical JSON).
No model anywhere in the compute path.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import sys

# BUNDLE PATCH (apostol-reconcile-demo release): path wiring only. This
# vendored copy lives at ai_node/auditor_skills/gate.py, the bundled
# node_core one level up (ai_node/node_core), and the H5 calibration
# scratch copy goes to a temp dir created at run time instead of a
# repo-side scratchpad path. NO conformance / poison / determinism check
# is weakened; the full H5 defect-injection calibration still runs.
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))               # ai_node/auditor_skills
NODE_AI_ROOT = os.path.dirname(HERE)                            # ai_node (bundled node_core lives here)
SCRATCH_DIR = tempfile.mkdtemp(prefix="auditor_gate_calib_")

if HERE not in sys.path:
    sys.path.insert(0, HERE)
if NODE_AI_ROOT not in sys.path:
    sys.path.insert(0, NODE_AI_ROOT)

from node_core import learn_unique, CORE_SHA16                   # the REAL vendored engine + sha lock
from auditor import SKILL                                        # the conformant auditor skill
from auditor import rollforward as rf, liabilities as liab, leadsheet as ls

CORE_ENGINE_PATH = os.path.join(NODE_AI_ROOT, "node_core", "engine.py")


def sha16(path):
    return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16]


def _v(x):
    """Normalize a result/expected to a comparable value: {value:..}->value, {refuse:..}->'REFUSE'."""
    if isinstance(x, dict):
        if "value" in x:
            return x["value"]
        if "refuse" in x:
            return "REFUSE"
    return x


class _HostileInput:
    """A __class__-raising object — the same hostile-object idiom node_core/selftest.py uses to
    prove `harden` catches even a result-inspection raise. Passed directly as `inp`."""
    @property
    def __class__(self):
        raise RuntimeError("__class__ raised during input inspection")


def main():
    R = {}
    confident_errors = []
    crashes = []

    RAW = {}
    for mod in (rf, liab, ls):
        RAW.update(mod.TOOLS)

    # ── H1: tool == oracle == anchor on every case; refuse-or-value on garbage; must-refuse ──
    h1_checked = 0
    for name in SKILL.tool_names():
        fn = lambda inp, n=name: SKILL.run(n, inp)
        oracle = SKILL.oracle(name)
        for inp, expected in SKILL.cases(name):
            out = fn(inp)
            if _v(expected) == "REFUSE":
                if "refuse" not in out:
                    confident_errors.append((name, "should-refuse case was ANSWERED", str(inp)[:60]))
                continue
            if "value" not in out:
                confident_errors.append((name, "refused a valid case", str(inp)[:60]))
                continue
            ov = oracle(inp)
            if not (_v(out) == _v(expected) == _v(ov)):
                confident_errors.append((name, "value != expected/oracle",
                                         f"got {_v(out)} exp {_v(expected)} oracle {_v(ov)}"))
            h1_checked += 1

    GARBAGE = [{}, None, [1, 2, 3], 42, "garbage", {"unexpected": "field"}, _HostileInput()]
    for name in SKILL.tool_names():
        for g in GARBAGE:
            try:
                out = SKILL.run(name, g)
                if not isinstance(out, dict) or ("value" not in out and "refuse" not in out):
                    confident_errors.append((name, "garbage gave non-result", str(g)[:40]))
            except Exception as e:
                crashes.append((name, "garbage", f"{type(e).__name__} on {str(g)[:30]}"))

    MUST_REFUSE = [
        ("leadsheet_tie", {"accounts": [], "reported": {"Cash": 0}}),
        ("leadsheet_tie", {"accounts": [{"id": "A9", "group": "Misc", "amount_pence": 1}],
                            "reported": {"Cash": 0}}),
        ("unrecorded_liability_flags", {"period_end": "2025-04-30", "threshold_pence": 50000,
                                         "payments": [{"id": "PX", "payment_date": "2025-04-31",
                                                       "service_period_end": "2025-04-10",
                                                       "amount_pence": 60000}]}),
    ]
    for tname, binp in MUST_REFUSE:
        if "refuse" not in SKILL.run(tname, binp):
            confident_errors.append((tname, "MUST-refuse input was ANSWERED", str(binp)[:60]))

    # Poison battery: the exact classes the mission names. Sub-penny stands in for ">2dp" — this
    # pack's money fields are integer pence, not decimal strings, so a fractional-pence float is
    # the structural analog of a too-many-decimal-places input.
    POISON_MUST_REFUSE = [
        ("rollforward_ending", {"beginning_balance": 1000000.5, "additions": 0, "disposals": 0}),      # float money
        ("rollforward_ending", {"beginning_balance": 1000000, "additions": -5000, "disposals": 0}),    # negative-where-invalid
        ("rollforward_ending", {"beginning_balance": 1000000, "additions": True, "disposals": 0}),     # bool
        ("rollforward_ending", {"beginning_balance": "1000000", "additions": 0, "disposals": 0}),      # string amount
        ("unrecorded_liability_flags", {"period_end": "2025-04-30", "threshold_pence": 50000.001,
                                         "payments": []}),                                              # sub-penny (>2dp analog)
        ("leadsheet_tie", {"accounts": [{"id": "A1", "group": "Cash", "amount_pence": 500000}]}),       # missing field (reported)
        ("rollforward_ending", _HostileInput()),                                                        # hostile object
    ]
    for tname, binp in POISON_MUST_REFUSE:
        if "refuse" not in SKILL.run(tname, binp):
            confident_errors.append((tname, "POISON input was ANSWERED", str(binp)[:70]))
    R["H1_poison_cases"] = len(POISON_MUST_REFUSE)
    R["H1_must_refuse_cases"] = len(MUST_REFUSE)
    R["H1_tool_cases_checked"] = h1_checked
    R["H1_n_tools"] = len(SKILL.tool_names())
    R["H1_crashes"] = crashes
    R["H1_pass"] = len(confident_errors) == 0 and len(crashes) == 0

    # ── H2: rollforward_ending -> rollforward_check compounding chain (independent recompute) ──
    asset = {"beginning_balance": 1000000, "additions": 250000, "disposals": 100000}
    ending = _v(SKILL.run("rollforward_ending", asset))
    tie = _v(SKILL.run("rollforward_check", {**asset, "tb_ending_balance": ending}))
    tie_break = _v(SKILL.run("rollforward_check", {**asset, "tb_ending_balance": ending - 1}))
    indep_ending = asset["beginning_balance"] + asset["additions"] - asset["disposals"]
    R["H2_chain"] = {"ending": ending, "tie": tie, "tie_break": tie_break, "indep_ending": indep_ending}
    R["H2_pass"] = (ending == indep_ending == 1150000 and tie is True and tie_break is False)

    # ── H3: node_core self-learning IDs the unrecorded-liability cutoff + refuses ambiguous ──
    cutoffs = [15, 30, 45]

    def metric(days_before_period_end, cutoff):
        # mirrors the real rule's shape: "service predates period-end by <= cutoff days" -> flag
        return "flag" if days_before_period_end <= cutoff else "no_flag"

    # tight examples bracket cutoff=30 exactly (30 flags, 31 does not) -> uniquely determined
    tight_ex = [(30, metric(30, 30)), (31, metric(31, 30))]
    tight_ho = [(10, metric(10, 30)), (45, metric(45, 30))]
    res_adopt = learn_unique(cutoffs, metric, tight_ex, [u for u, _ in tight_ho])
    # loose examples: only (5, flag) and (60, no_flag) — 15, 30 and 45 all fit -> ambiguous
    loose_ex = [(5, metric(5, 30)), (60, metric(60, 30))]
    res_refuse = learn_unique(cutoffs, metric, loose_ex, [10, 45])

    R["H3_self_learning"] = {
        "adopted_cutoff": res_adopt.get("comp"),
        "is_correct": res_adopt.get("comp") == 30,
        "ambiguous_refused": not res_refuse.get("adopted"),
        "ambiguous_reason": res_refuse.get("reason", "")[:80],
        "loop_is_node_core": learn_unique.__module__.endswith("node_core.loop"),
    }
    R["H3_pass"] = (R["H3_self_learning"]["is_correct"] and R["H3_self_learning"]["ambiguous_refused"]
                    and R["H3_self_learning"]["loop_is_node_core"])

    # ── H4: judgment boundary refuses every judgment item; mechanical tools still compute ──
    from auditor import judgment as judg
    judged = [(q, SKILL.judge(q)) for q, _ in judg.JUDGMENT_REFUSALS]
    all_refused = all("refuse" in r and "value" not in r for _, r in judged)
    rule_ok = _v(SKILL.run("rollforward_ending", asset)) == 1150000
    R["H4_judgment"] = {"n_judgment": len(judged), "all_refused": all_refused,
                        "sample_refusal": judged[0][1].get("refuse", "")[:70] if judged else None,
                        "rule_checks_compute": rule_ok}
    R["H4_pass"] = all_refused and rule_ok

    # ── H5: calibration — off-by-one defect in a SCRATCH COPY of a leaf, never the real file ──
    os.makedirs(SCRATCH_DIR, exist_ok=True)
    real_src = open(os.path.join(HERE, "auditor", "rollforward.py")).read()
    marker = "ending = beg + add - dis\n"
    assert marker in real_src, "calibration fixture assumption broke: marker line not found"
    defective_src = real_src.replace(marker, "ending = beg + add - dis + 1  # INJECTED OFF-BY-ONE DEFECT\n")
    assert defective_src != real_src
    defect_path = os.path.join(SCRATCH_DIR, "rollforward_defective.py")
    with open(defect_path, "w") as f:
        f.write(defective_src)

    spec = importlib.util.spec_from_file_location("rollforward_defective", defect_path)
    defective_mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(defective_mod)

    calib_asset = {"beginning_balance": 1000000, "additions": 250000, "disposals": 100000}
    defective_out = defective_mod.rollforward_ending(calib_asset)
    real_oracle_val = rf.oracle_rollforward_ending(calib_asset)
    fires = defective_out["value"] != real_oracle_val
    fire_detail = (f"defective fn={defective_out['value']!r} vs REAL independent oracle="
                   f"{real_oracle_val!r} on {calib_asset} -> MISMATCH DETECTED" if fires
                   else "defect did not fire (calibration instrument invalid)")

    # restore: nothing to restore (the real file was never touched) — prove it directly
    real_out = rf.rollforward_ending(calib_asset)
    clean = real_out["value"] == real_oracle_val == 1150000
    os.remove(defect_path)   # discard the scratch copy

    R["H5_calibration"] = {"defect_fires": fires, "fire_detail": fire_detail, "restored_clean": clean,
                           "defect_marker": marker.strip(), "scratch_path_used": defect_path}
    R["core_sha16"] = sha16(CORE_ENGINE_PATH)
    R["H5_core_unchanged"] = R["core_sha16"] == CORE_SHA16
    R["H5_pass"] = fires and clean and R["H5_core_unchanged"]

    R["confident_errors"] = confident_errors
    R["WIN"] = all(R[k] for k in ("H1_pass", "H2_pass", "H3_pass", "H4_pass", "H5_pass"))
    return R


if __name__ == "__main__":
    R1 = main()
    R2 = main()   # second full run for the determinism check below
    blob1 = json.dumps(R1, indent=2, sort_keys=True, default=str)
    blob2 = json.dumps(R2, indent=2, sort_keys=True, default=str)
    determinism = blob1 == blob2

    open(os.path.join(HERE, "results.json"), "w").write(blob1)

    summary = {k: R1[k] for k in ("H1_pass", "H2_pass", "H3_pass", "H4_pass", "H5_pass", "WIN")}
    summary.update({
        "H1_tool_cases": R1["H1_tool_cases_checked"], "n_tools": R1["H1_n_tools"],
        "confident_errors": len(R1["confident_errors"]), "crashes": len(R1["H1_crashes"]),
        "adopted_cutoff": R1["H3_self_learning"]["adopted_cutoff"],
        "ambiguous_refused": R1["H3_self_learning"]["ambiguous_refused"],
        "calibration_fires": R1["H5_calibration"]["defect_fires"],
        "calibration_fire_detail": R1["H5_calibration"]["fire_detail"],
        "core_sha16": R1["core_sha16"], "determinism": determinism,
    })
    print(json.dumps(summary, indent=2))
    sys.exit(0 if (R1["WIN"] and determinism) else 1)
