#!/usr/bin/env python3
from __future__ import annotations
import argparse, hashlib, json, stat, zipfile
from pathlib import Path
from typing import Any

CONTROLS={"candidate-manifest.json","candidate-checkpoint.json","hash-inventory.json","verifier-input.json"}
CORE="a113a57564a11710dffd238688dd73f8037eabaed0b48532b4bc90b2996e69fc"
WHEEL="2fbd6c9ad9d4b5ee979e7671d44e32fd4769e8737e26d7459fe73c81a525c656"
PRED="30bcd6633875050aa4f56f49a8bb26cd616ffa2409fc04caef523a8a62d5d1cc"
TEST="tests/gateway/test_dualcoach_activation_cutover.py"
TEST_SHA="7625214cc0567624904ffb2eae8df5c00c58a4b72601f43eec10b367506e024a"
DIFF_SHA="79837de06312c74e04fd70a22fc16e849b864983b8804c47757b41c68441ea36"

def canonical(v:object)->bytes:return json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=True,allow_nan=False).encode()
def sha(b:bytes)->str:return hashlib.sha256(b).hexdigest()
def fsha(p:Path)->str:return sha(p.read_bytes())
def obj(p:Path)->dict[str,Any]:
 v=json.loads(p.read_bytes());
 if not isinstance(v,dict):raise AssertionError(f"object required: {p}")
 return v
def lsha(v:object)->str:
 if not isinstance(v,list):raise AssertionError("list required")
 return sha(canonical(v))
def safe(root:Path)->None:
 if root.is_symlink() or not root.is_dir() or stat.S_IMODE(root.stat().st_mode)!=0o500:raise AssertionError("unsafe root")
 for p in root.rglob('*'):
  if p.is_symlink():raise AssertionError(f"symlink: {p}")
  mode=stat.S_IMODE(p.stat().st_mode)
  if p.is_dir() and mode!=0o500:raise AssertionError(f"directory mode: {p}")
  if p.is_file() and (mode!=0o400 or p.stat().st_nlink!=1):raise AssertionError(f"file mode/link: {p}")
def rows(root:Path)->list[dict[str,object]]:
 return [{"path":str(p.relative_to(root)),"bytes":p.stat().st_size,"sha256":fsha(p)} for p in sorted(root.rglob('*')) if p.is_file()]

def verify(inp:Path)->dict[str,object]:
 vi=obj(inp); root=Path(str(vi['candidate_root'])); safe(root)
 m=obj(root/'candidate-manifest.json'); cp=obj(root/'candidate-checkpoint.json'); inv=obj(root/'hash-inventory.json')
 if fsha(root/'candidate-manifest.json')!=vi['manifest_sha256'] or fsha(root/'candidate-checkpoint.json')!=vi['checkpoint_sha256']:raise AssertionError('control hash drift')
 full=m.pop('full_candidate_digest',None); core=m.pop('core_candidate_digest',None)
 calc=sha(canonical({'core_candidate_digest':CORE,'successor_envelope':m}))
 if core!=CORE or full!=calc or vi['full_candidate_digest']!=full:raise AssertionError('digest mismatch')
 m['core_candidate_digest']=core;m['full_candidate_digest']=full
 entries=inv.get('entries')
 if not isinstance(entries,list) or lsha(entries)!=inv.get('entries_sha256') or len(entries)!=inv.get('entry_count'):raise AssertionError('inventory digest')
 indexed={x['path'] for x in entries}; actual={str(p.relative_to(root)) for p in root.rglob('*') if p.is_file()}
 if actual!=indexed|CONTROLS or indexed&CONTROLS:raise AssertionError(f"unindexed bytes: {sorted(actual-indexed-CONTROLS)}")
 for x in entries:
  p=root/x['path']
  if p.stat().st_size!=x['bytes'] or fsha(p)!=x['sha256']:raise AssertionError(f"inventory drift: {x['path']}")
 predroot=root/'historical/30b-candidate'; pm=obj(predroot/'candidate-manifest.json')
 if pm['full_candidate_digest']!=PRED or pm['core_candidate_digest']!=CORE or pm['wheel']['sha256']!=WHEEL:raise AssertionError('30b lineage drift')
 tree=obj(root/'bindings/30b-tree-inventory.json')
 observed=rows(predroot)
 if observed!=tree['entries'] or lsha(observed)!=tree['entries_sha256']:raise AssertionError('30b copied tree drift')
 evidence=obj(root/'bindings/gate-evidence-tree-inventory.json'); eroot=root/'historical/gate18-20-evidence'; observed_e=rows(eroot)
 if observed_e!=evidence['entries'] or lsha(observed_e)!=evidence['entries_sha256']:raise AssertionError('gate evidence drift')
 old=obj(predroot/'bindings/executable-closure.json'); new=obj(root/'bindings/executable-closure.json')
 if lsha(new['entries'])!=new['entries_sha256']:raise AssertionError('closure digest')
 a={x['path']:x for x in old['entries']};b={x['path']:x for x in new['entries']};delta=[{'path':p,'before':a.get(p),'after':b.get(p)} for p in sorted(set(a)|set(b)) if a.get(p)!=b.get(p)]
 if len(delta)!=1 or delta[0]['path']!=TEST or delta[0]['after']['sha256']!=TEST_SHA:raise AssertionError('unexpected successor executable delta')
 declared=obj(root/'bindings/source-delta-manifest.json')
 if declared['exact_delta']!=delta or declared['test_sha256']!=TEST_SHA or declared['diff_sha256']!=DIFF_SHA:raise AssertionError('declared delta drift')
 if fsha(root/'source-delta/after'/TEST)!=TEST_SHA or fsha(root/'source-delta/isolation-fix.diff')!=DIFF_SHA:raise AssertionError('delta artifact drift')
 # Production entries and the cumulative five-path executable delta remain unchanged from 30b.
 prod=m['production_invariance']['paths']
 for p in prod:
  if a[p]!=b[p]:raise AssertionError(f"production closure drift: {p}")
 older=obj(predroot/pm['lineage']['predecessor_closure_path']); z={x['path']:x for x in older['entries']}; cumulative=[p for p in sorted(set(z)|set(b)) if z.get(p)!=b.get(p)]
 if cumulative!=m['production_invariance']['cumulative_executable_delta_paths'] or len(cumulative)!=5:raise AssertionError('cumulative executable delta path drift')
 wheel=root/'artifacts/hermes_agent-0.17.0-py3-none-any.whl'
 if fsha(wheel)!=WHEEL or wheel.read_bytes()!=(predroot/'artifacts/hermes_agent-0.17.0-py3-none-any.whl').read_bytes():raise AssertionError('wheel drift')
 with zipfile.ZipFile(wheel) as zf:
  for p in prod:
   if zf.read(p)!=(root/'production-source'/p).read_bytes():raise AssertionError(f"source/wheel parity: {p}")
 if (root/'status/pre.nul').read_bytes()!=(root/'status/post.nul').read_bytes():raise AssertionError('status drift')
 checks={'minimal-order-7-tests.txt':'7 passed','focused-49-tests.txt':'49 passed','wheel-installed-tests.txt':'40 passed','ruff.txt':'All checks passed!','compile.txt':'compile_exit=0'}
 for name,text in checks.items():
  if text not in (root/'verification'/name).read_text():raise AssertionError(f"gate receipt: {name}")
 types=obj(root/'verification/basedpyright-new-files.json')['summary']
 if (types['errorCount'],types['warningCount'])!=(0,0):raise AssertionError('type gate')
 expected={'schema':'task26-test-isolated-successor-checkpoint-v1','status':'PASS_READY_FOR_GATE20_RERUN_NOT_DEPLOYED','full_candidate_digest':full,'core_candidate_digest':CORE,'manifest_sha256':vi['manifest_sha256'],'wheel_sha256':WHEEL,'predecessor_full_digest':PRED,'source_delta_count':1,'executable_delta_count':1,'inventory_entry_count':len(entries),'minimal_order_test_count':7,'focused_test_count':49,'wheel_test_count':40}
 if cp!=expected:raise AssertionError('checkpoint drift')
 return {'status':'PASS','full_candidate_digest':full,'core_candidate_digest':CORE,'wheel_sha256':WHEEL,'source_delta_count':1,'executable_delta_count':1,'inventory_entry_count':len(entries),'unindexed_byte_count':0,'preserved_30b_leaf_count':len(observed),'preserved_gate_evidence_leaf_count':len(observed_e),'production_parity_count':3,'minimal_order_test_count':7,'focused_test_count':49,'wheel_test_count':40,'ready_for_gate20_rerun':True}
def main()->int:
 p=argparse.ArgumentParser();p.add_argument('input',type=Path);a=p.parse_args();print(json.dumps(verify(a.input),sort_keys=True,separators=(',',':')));return 0
if __name__=='__main__':raise SystemExit(main())
