#!/usr/bin/env python3
"""Build the deterministic compact OID conditional-freeze manifest."""
from __future__ import annotations
import argparse, hashlib, json, os
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable

SCHEMA="oid-conditional-freeze/v1"; RUN=Path("src/OID_analysis/runs/active-two-scene-v2")
AUDIT=RUN/"OID_PHASE4_AUDIT_20260723T1510Z.json"
SCENES=("karman_re100","illusion_1.0L"); REALIZATIONS=("r1","r2","r3"); OBSERVABLES=("delta_forces","delta_sensors")
DOCS=tuple(f"src/OID_analysis/{x}" for x in ("README.md","PIPELINE.md","RESULTS.md","OID_METHOD.md","archive/RELOCATION.md"))
SCRIPTS=tuple(f"src/OID_analysis/scripts/{x}.py" for x in ("rebuild_phase3_canonical","run_phase3_5_formal","plot_phase4_summary"))
V2=tuple(f"src/OID_analysis/v2/{x}.py" for x in ("__init__","alignment","analysis","artifacts","audit","collect","contracts","geometry","gpu","layout","manifest","oid","online","parent","twin"))
CODE=("src/OID_analysis/configs.py",)+SCRIPTS+V2
DEPENDENCIES=("LegacyCelerisLab/driver.py","src/CCD_analysis/utils/dual_clock.py","src/CCD_analysis/utils/run_contract.py")
PROVENANCE=("src/OID_analysis/runs/ACTIVE_EVIDENCE.json","src/OID_analysis/archive/RELOCATION_MANIFEST.json","src/OID_analysis/archive/RUNS_RELOCATION_MANIFEST.json","src/OID_analysis/provenance/contradictions/steady_reanalysis.json")
FIGURES=tuple(f"summary/{stem}.{ext}" for stem in ("01_primary_vs_deranged","02_selection_adjusted_periodic_excluded_p_values","03_force_state_reconstruction") for ext in ("png","pdf"))
CANONICAL=tuple(str(RUN/"canonical"/s/r/f"paired_product.{ext}") for s in SCENES for r in REALIZATIONS for ext in ("json","npz"))
ANALYSES=tuple(str(RUN/"analysis"/s/o/name) for s in SCENES for o in OBSERVABLES for name in ("phase_3_5_report.json","phase_3_5_models.npz","verification.json"))
SUMMARY=(str(RUN/"summary/phase4_summary.json"),)+tuple(str(RUN/x) for x in FIGURES)
ALL_FILES=DOCS+CODE+DEPENDENCIES+CANONICAL+ANALYSES+(str(AUDIT),)+SUMMARY+PROVENANCE

def _load(path:Path)->dict[str,Any]:
 value=json.loads(path.read_text(encoding="utf-8"))
 if not isinstance(value,dict): raise ValueError(f"JSON object required: {path}")
 return value

def _sha(path:Path)->str:
 h=hashlib.sha256()
 with path.open("rb") as f:
  for block in iter(lambda:f.read(1<<20),b""): h.update(block)
 return h.hexdigest()

def _record(repo:Path,rel:str)->dict[str,Any]:
 path=repo/rel
 if not path.is_file(): raise FileNotFoundError(f"required freeze path missing: {rel}")
 return {"path":rel,"sha256":_sha(path),"size_bytes":path.stat().st_size}

def _expect(root:Path, expected:Iterable[str], label:str, recursive:bool=True)->None:
 paths=root.rglob("*") if recursive else root.glob("*.py")
 actual={str(p.relative_to(root)) for p in paths if p.is_file()}; wanted=set(expected)
 if actual!=wanted: raise RuntimeError(f"{label} path set mismatch; missing={sorted(wanted-actual)}, extra={sorted(actual-wanted)}")

def _contracts(repo:Path, rec:dict[str,dict[str,Any]])->tuple[dict[str,Any],dict[str,Any],dict[str,Any]]:
 audit,summary,active=_load(repo/AUDIT),_load(repo/SUMMARY[0]),_load(repo/PROVENANCE[0])
 if (audit.get("schema_id"),audit.get("overall")) != ("oid-phase4-audit/v3","conditional"): raise RuntimeError("wrong final audit schema/status")
 formal=audit.get("gates",{}).get("formal_complete",{})
 if formal.get("status")!="conditional" or formal.get("evidence",{}).get("declared_status")!="fail_closed": raise RuntimeError("formal_complete must be conditional/fail_closed")
 jobs=audit.get("gates",{}).get("formal_analyses",{}).get("evidence",{}); promoted=[f"{s}/delta_sensors" for s in SCENES]; rejected=[f"{s}/delta_forces" for s in SCENES]
 if jobs.get("promoted_jobs")!=promoted or jobs.get("not_promoted_jobs")!=rejected: raise RuntimeError("unexpected promoted/not-promoted jobs")
 for s in SCENES:
  for o in OBSERVABLES:
   base=RUN/"analysis"/s/o; rr,mr,vr=map(str,(base/"phase_3_5_report.json",base/"phase_3_5_models.npz",base/"verification.json")); report,verify=_load(repo/rr),_load(repo/vr)
   if report.get("schema_id")!="oid-phase3-5-loro/v2" or verify.get("schema_id")!="oid-phase3-5-verification/v1": raise RuntimeError(f"analysis schema mismatch: {s}/{o}")
   if verify.get("report_sha256")!=rec[rr]["sha256"] or verify.get("model_sha256")!=rec[mr]["sha256"]: raise RuntimeError(f"analysis triple hash mismatch: {s}/{o}")
   if jobs.get("jobs",{}).get(f"{s}/{o}",{}).get("evidence",{}).get("report_sha256")!=rec[rr]["sha256"]: raise RuntimeError(f"audit/report hash mismatch: {s}/{o}")
 if (summary.get("schema_id"),summary.get("overall")) != ("oid-phase4-compact-summary/v2","conditional"): raise RuntimeError("wrong summary schema/status")
 sources={str(AUDIT):rec[str(AUDIT)]["sha256"]}|{str(RUN/"analysis"/s/o/"phase_3_5_report.json"):rec[str(RUN/"analysis"/s/o/"phase_3_5_report.json")]["sha256"] for s in SCENES for o in OBSERVABLES}
 if summary.get("source_sha256")!=sources: raise RuntimeError("summary source hash mismatch")
 figures=summary.get("figure_artifacts",{})
 if set(figures)!={Path(x).name for x in FIGURES}: raise RuntimeError("summary figure path set mismatch")
 for rel in FIGURES:
  if figures[Path(rel).name].get("sha256")!=rec[str(RUN/rel)]["sha256"]: raise RuntimeError(f"summary figure hash mismatch: {rel}")
 if active.get("schema_id")!="oid-active-evidence/v1" or active.get("formal_complete",{}).get("status")!="fail_closed": raise RuntimeError("wrong ACTIVE_EVIDENCE contract")
 for s in SCENES:
  if [x.get("realization_id") for x in active.get("scenes",{}).get(s,{}).get("realizations",[])]!=list(REALIZATIONS): raise RuntimeError(f"realization mismatch: {s}")
 return audit,summary,active

def build(repo:Path,created_utc:str)->tuple[dict[str,Any],dict[str,dict[str,Any]]]:
 repo=repo.resolve(); stamp=datetime.fromisoformat(created_utc.replace("Z","+00:00"))
 if stamp.tzinfo is None or stamp.utcoffset() is None or stamp.utcoffset().total_seconds()!=0: raise ValueError("--created-utc must be ISO-8601 UTC")
 _expect(repo/RUN/"canonical",(str(Path(x).relative_to(RUN/"canonical")) for x in CANONICAL),"canonical")
 _expect(repo/RUN/"analysis",(str(Path(x).relative_to(RUN/"analysis")) for x in ANALYSES),"analysis")
 _expect(repo/RUN/"summary",(str(Path(x).relative_to(RUN/"summary")) for x in SUMMARY),"summary")
 _expect(repo/"src/OID_analysis/v2",(Path(x).name for x in V2),"active v2 modules", recursive=False)
 rec={x:_record(repo,x) for x in ALL_FILES}; audit,summary,active=_contracts(repo,rec); archives=[]
 for rel,schema in ((PROVENANCE[1],"oid-relocation-v1"),(PROVENANCE[2],"oid-runs-relocation-v1")):
  value=_load(repo/rel)
  if value.get("schema_version")!=schema: raise RuntimeError(f"relocation schema mismatch: {rel}")
  archives.append({"manifest":rec[rel],"archive_root":value.get("destination_root"),"payload_root":value.get("payload_root"),"relocated_roots":value.get("roots")})
 manifest={"schema_id":SCHEMA,"created_utc":created_utc,"scope":"conditional OID freeze; formal COMPLETE remains fail-closed","scene_physics":{s:{"Re_code":100,"Re_D":50,"U0":.01,"nu":.004,"D":20.0} for s in SCENES},"realization_ids":list(REALIZATIONS),"overall_status":audit["overall"],"promoted_jobs":summary["promoted_jobs"],"not_promoted_jobs":summary["not_promoted_jobs"],"formal_complete":audit["gates"]["formal_complete"],"archive_bindings":archives,"files":[rec[x] for x in ALL_FILES],"commands":{"generate":"PYTHONPATH=src CUDA_VISIBLE_DEVICES='' conda run -n pycuda_3_10 python3 src/OID_analysis/scripts/build_freeze_manifest.py --created-utc <UTC> --output <fresh.json>","test":"PYTHONPATH=src CUDA_VISIBLE_DEVICES='' conda run -n pycuda_3_10 python3 -m pytest -q src/OID_analysis/tests/test_freeze_manifest.py","contract":"CPU-only; explicit fresh output; exact active path inventory; no evidence mutation"},"source_contract":{"audit_schema":audit["schema_id"],"summary_schema":summary["schema_id"],"active_evidence_schema":active["schema_id"]}}
 return manifest,rec

def run(repo:Path,output:Path,created_utc:str)->dict[str,Any]:
 repo=repo.resolve(); dst=output.resolve()
 if dst.exists(): raise FileExistsError(f"refusing to clobber existing output: {dst}")
 if dst.is_relative_to(repo/RUN): raise ValueError("output must not mutate active evidence")
 manifest,before=build(repo,created_utc); data=json.dumps(manifest,indent=2,sort_keys=True,allow_nan=False)+"\n"
 if {x:_record(repo,x) for x in ALL_FILES}!=before: raise RuntimeError("evidence changed during JSON assembly")
 dst.parent.mkdir(parents=True,exist_ok=True); fd=os.open(dst,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o644)
 with os.fdopen(fd,"w",encoding="utf-8") as f: f.write(data)
 return manifest

def main()->int:
 p=argparse.ArgumentParser(description=__doc__); p.add_argument("--repo",type=Path,default=Path(__file__).resolve().parents[3]); p.add_argument("--created-utc",required=True); p.add_argument("--output",type=Path,required=True); a=p.parse_args(); m=run(a.repo,a.output,a.created_utc); print(json.dumps({"schema_id":m["schema_id"],"output":str(a.output.resolve()),"file_count":len(m["files"])},sort_keys=True)); return 0
if __name__=="__main__": raise SystemExit(main())
