from __future__ import annotations
import hashlib, importlib.util, json
from pathlib import Path
import pytest
SCRIPT=Path(__file__).parents[3]/"src/OID_analysis/scripts/build_freeze_manifest.py"; SPEC=importlib.util.spec_from_file_location("freeze",SCRIPT); assert SPEC and SPEC.loader
freeze=importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(freeze)
def sha(p:Path)->str:return hashlib.sha256(p.read_bytes()).hexdigest()
def write(root:Path,rel:str,data:bytes=b"fixture\n")->Path:
 p=root/rel;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);return p
def js(root:Path,rel:str,value:dict)->Path:return write(root,rel,(json.dumps(value,sort_keys=True)+"\n").encode())
@pytest.fixture
def repo(tmp_path:Path)->Path:
 for rel in freeze.ALL_FILES:write(tmp_path,rel)
 js(tmp_path,freeze.PROVENANCE[0],{"schema_id":"oid-active-evidence/v1","formal_complete":{"status":"fail_closed"},"scenes":{s:{"realizations":[{"realization_id":r} for r in freeze.REALIZATIONS]} for s in freeze.SCENES}})
 js(tmp_path,freeze.PROVENANCE[1],{"schema_version":"oid-relocation-v1","destination_root":"/archive/dev","roots":["old"]});js(tmp_path,freeze.PROVENANCE[2],{"schema_version":"oid-runs-relocation-v1","destination_root":"/archive/runs","payload_root":"/archive/runs/payload"})
 jobs={}
 for s in freeze.SCENES:
  for o in freeze.OBSERVABLES:
   base=freeze.RUN/"analysis"/s/o; report=js(tmp_path,str(base/"phase_3_5_report.json"),{"schema_id":"oid-phase3-5-loro/v2"}); model=tmp_path/base/"phase_3_5_models.npz"; js(tmp_path,str(base/"verification.json"),{"schema_id":"oid-phase3-5-verification/v1","report_sha256":sha(report),"model_sha256":sha(model)});jobs[f"{s}/{o}"]={"evidence":{"report_sha256":sha(report)}}
 promoted=[f"{s}/delta_sensors" for s in freeze.SCENES]; rejected=[f"{s}/delta_forces" for s in freeze.SCENES]
 audit=js(tmp_path,str(freeze.AUDIT),{"schema_id":"oid-phase4-audit/v3","overall":"conditional","gates":{"formal_complete":{"status":"conditional","evidence":{"declared_status":"fail_closed"}},"formal_analyses":{"evidence":{"jobs":jobs,"promoted_jobs":promoted,"not_promoted_jobs":rejected}}}})
 sources={str(freeze.AUDIT):sha(audit)}|{str(freeze.RUN/"analysis"/s/o/"phase_3_5_report.json"):sha(tmp_path/freeze.RUN/"analysis"/s/o/"phase_3_5_report.json") for s in freeze.SCENES for o in freeze.OBSERVABLES}; figures={Path(rel).name:{"sha256":sha(tmp_path/freeze.RUN/rel)} for rel in freeze.FIGURES}
 js(tmp_path,freeze.SUMMARY[0],{"schema_id":"oid-phase4-compact-summary/v2","overall":"conditional","promoted_jobs":promoted,"not_promoted_jobs":rejected,"source_sha256":sources,"figure_artifacts":figures});return tmp_path
def test_determinism_and_no_clobber(repo:Path,tmp_path:Path)->None:
 one,two=tmp_path/"one.json",tmp_path/"two.json"; stamp="2026-07-24T00:00:00Z";freeze.run(repo,one,stamp);freeze.run(repo,two,stamp);assert one.read_bytes()==two.read_bytes();assert json.loads(one.read_text())["schema_id"]==freeze.SCHEMA
 with pytest.raises(FileExistsError):freeze.run(repo,one,stamp)
@pytest.mark.parametrize("mode",["tamper","missing","extra"])
def test_tamper_missing_extra_fail(repo:Path,tmp_path:Path,mode:str)->None:
 report=repo/freeze.RUN/"analysis/karman_re100/delta_forces/phase_3_5_report.json"
 if mode=="tamper":report.write_text('{"schema_id":"oid-phase3-5-loro/v2","tampered":true}\n')
 elif mode=="missing":report.unlink()
 else:write(repo,str(freeze.RUN/"canonical/unexpected.json"))
 with pytest.raises((FileNotFoundError,RuntimeError)):freeze.run(repo,tmp_path/f"{mode}.json","2026-07-24T00:00:00Z")
