from pathlib import Path
import json, hashlib, copy
ROOT=Path(__file__).resolve().parents[1]
class LedgerError(Exception): pass
class DuplicatePosting(LedgerError): pass
class InvalidCorrection(LedgerError): pass
ALLOWED_ENTRY_TYPES={'DEBIT','CREDIT','CORRECTION'}
class Ledger:
    def __init__(self, entries=None):
        self.entries=[]; self.keys=set(); self.ids=set()
        for e in entries or []: self.append(e)
    def append(self,e):
        e=copy.deepcopy(e); required=['entry_id','business_key','entry_type','amount_delta','corrects_entry','accepted_at']
        if any(k not in e for k in required): raise LedgerError('missing field')
        if e['entry_type'] not in ALLOWED_ENTRY_TYPES: raise LedgerError('invalid entry_type')
        if e['entry_id'] in self.ids: raise LedgerError('duplicate entry_id')
        if e['business_key'] in self.keys: raise DuplicatePosting(e['business_key'])
        if e['corrects_entry'] is not None and e['corrects_entry'] not in self.ids: raise InvalidCorrection(e['corrects_entry'])
        if e['corrects_entry'] is not None and e['entry_type']!='CORRECTION': raise InvalidCorrection('correction entry_type must be CORRECTION')
        self.entries.append(e); self.ids.add(e['entry_id']); self.keys.add(e['business_key'])
    def replay_entries(self): return sorted(self.entries,key=lambda x:(int(x['accepted_at']),x['entry_id']))
    def projection_trace(self):
        bal=0; out=[]
        for e in self.replay_entries(): bal+=int(e['amount_delta']); out.append({'entry_id':e['entry_id'],'balance':bal})
        return out
    def projection(self):
        trace=self.projection_trace(); return trace[-1]['balance'] if trace else 0
    def canonical_history(self): return json.dumps(self.replay_entries(),sort_keys=True,separators=(',',':'))
    def history_hash(self): return hashlib.sha256(self.canonical_history().encode()).hexdigest()
    def lineage_ok(self):
        ids={e['entry_id'] for e in self.entries}
        return all(e['corrects_entry'] is None or (e['corrects_entry'] in ids and e['entry_type']=='CORRECTION') for e in self.entries)
BASE=json.loads((ROOT/'fixture/baseline-ledger.json').read_text())['entries']
CORRECTION={'entry_id':'E2','business_key':'corr-001','entry_type':'CORRECTION','amount_delta':2000,'corrects_entry':'E1','accepted_at':2}
def run_probe(case_id):
    if case_id=='baseline_append_correction':
        led=Ledger(BASE); before=led.history_hash(); led.append(CORRECTION)
        return {'case_id':case_id,'classification':'CORRECTION_APPENDED_AND_REPLAYABLE','before_prefix_hash':before,'original_entry_preserved':led.entries[0]==BASE[0],'projection':led.projection(),'projection_trace':led.projection_trace(),'replay_order':[e['entry_id'] for e in led.replay_entries()],'lineage_ok':led.lineage_ok(),'entries':copy.deepcopy(led.entries)}
    if case_id=='mutate_original_history':
        led=Ledger(BASE); before=led.history_hash(); led.entries[0]['amount_delta']=-10000; after=led.history_hash(); return {'case_id':case_id,'classification':'HISTORY_MUTATION_DETECTED' if before!=after else 'UNDETECTED','before_hash':before,'after_hash':after,'projection':led.projection()}
    if case_id=='duplicate_posting_identity':
        led=Ledger(BASE); n=len(led.entries)
        try: led.append({'entry_id':'E9','business_key':'debit-001','entry_type':'DEBIT','amount_delta':-1,'corrects_entry':None,'accepted_at':9}); cls='DUPLICATE_ACCEPTED'
        except DuplicatePosting: cls='DUPLICATE_POSTING_REJECTED'
        return {'case_id':case_id,'classification':cls,'history_unchanged':len(led.entries)==n,'projection':led.projection()}
    if case_id=='missing_correction_link':
        led=Ledger(BASE); bad=dict(CORRECTION); bad['corrects_entry']=None; led.append(bad); return {'case_id':case_id,'classification':'CORRECTION_LINEAGE_MISSING' if led.projection()==-10000 and bad['corrects_entry'] is None else 'UNEXPECTED','projection':led.projection(),'numeric_projection_correct':led.projection()==-10000,'lineage_explicit':False}
    if case_id=='evidence_field_removed':
        required={'Entry ID','Business Key','Entry Type','Amount Delta','Corrects Entry','Accepted At','Projection Effect','Evidence','Residual Domain Rule'}; row={k:'x' for k in required}; row.pop('Residual Domain Rule'); return {'case_id':case_id,'classification':'EVIDENCE_INCOMPLETE' if set(row)!=required else 'COMPLETE','missing':['Residual Domain Rule']}
    raise KeyError(case_id)
def publisher_selftest():
    cases=json.loads((ROOT/'fixture/probe-cases.json').read_text())['cases']; obs=[run_probe(c['id']) for c in cases]; exp={c['id']:c['expected_class'] for c in cases}; checks=[o['classification']==exp[o['case_id']] for o in obs]
    b=next(o for o in obs if o['case_id']=='baseline_append_correction'); checks += [b['projection']==-10000,b['original_entry_preserved'] is True,b['lineage_ok'] is True,b['entries'][1]['entry_type']=='CORRECTION',b['replay_order']==['E1','E2'],b['projection_trace']==[{'entry_id':'E1','balance':-12000},{'entry_id':'E2','balance':-10000}]]
    led=Ledger(BASE); h1=led.history_hash(); led.entries[0]['business_key']='changed'; checks.append(h1!=led.history_hash())
    try: Ledger(BASE).append({'entry_id':'E3','business_key':'bad-corr','entry_type':'CREDIT','amount_delta':1,'corrects_entry':'E1','accepted_at':3}); checks.append(False)
    except InvalidCorrection: checks.append(True)
    probe=Ledger(BASE); probe.projection_trace=lambda:[{'entry_id':'X','balance':321}]; checks.append(probe.projection()==321)
    return {'cases':obs,'checks':checks,'pass':all(checks),'runtime_scope':'PYTHON_IN_MEMORY_FIXTURE_ONLY','postgresql_runtime_claimed':False}
