#!/usr/bin/env python3
"""Strict comparison of private evidence. No database/network calls or raw output."""
import collections,copy,hashlib,json,pathlib,re,sys

CASES=['MY_BEFORE','CP_DEFAULT','RECEIVE','MY_DEFAULT','REPEAT','CP_CLAIMED','MY_USABLE','MY_LOW_AMOUNT','MY_THRESHOLD','MY_IDS_POSITIVE','MY_IDS_RESTORED','CP_JSON_QUOTES','CP_JSON_32767','CP_JSON_32768','MY_CLEANED','CP_CLEANED']
COUPON_SHA='17f35c7471ae9ffdd5e824a49a7da6ba4a3ee8f35d1445b8987348202ef131da'
META={'2a4d84558495d73d7809ba6d9f57ad56443c9429ac83f881b661f7334874b78f','2a2aae4e127669b3ba48830d9be8ba24b3d9f37739bfcbf87ebbf64d5a4e74bc','0e0ff06ada018c16c9501ac0c101f53d5eed7076e0be5ef7cb238cd0a19a103e','b8f09a2aaf171bf3237520f7a8b6197b88232d4544f03940d64290e3c52f8396','b6a898d6fda64698bbad8fdb7e08c18c963dcce99153053621a854e91557dd8c','ef576fae8fab2fb6a1d9168dc9e81b77d65a6ac32fee971bdae2816ace94ff95','3a9d6cad4110830e8507ca28a4cd0367a11000e14d164217c4f941ceb8eb43a7','4a396387184cd75b1e087598865d111461f0e701e0100bddd630da7226a51437','847ed68cc19d239df379bde34efe363823e8df439c0180b14ae55add20094d9a','a38b68a2302e0e06d0644b8e53dc0180946636fcd9129c58fe5eee5fa432e506','f811572d7c9a5f7ae66140f946d378dfa1bc670329db10a02524a1f74f74fe64'}
# Store::detail eagerly reads logoImage; Mysql getFields for yoshop_upload_file is metadata too.
META.add('de2a7500a568296661933c1fa68bd96cceb1c9f622e21ecf991b4bfb9af58a27')
def check(ok,label):
    if not ok:raise AssertionError(label)
def strict(a,b,path='$'):
    check(type(a) is type(b),'type mismatch '+path)
    if isinstance(a,dict):
        check(a.keys()==b.keys(),'field mismatch '+path)
        for k in a:strict(a[k],b[k],path+'.'+k)
    elif isinstance(a,list):
        check(len(a)==len(b),'array length '+path)
        for i,(x,y) in enumerate(zip(a,b)):strict(x,y,path+'['+str(i)+']')
    else:check(a==b,'value mismatch '+path)
def normalized(body,ids):
    value=copy.deepcopy(body)
    def walk(v):
        if isinstance(v,dict):
            for k,x in list(v.items()):
                if k=='trace_id':check(type(x) is str and re.fullmatch('[a-f0-9]{13}',x),'trace contract');v[k]='<REQUEST_TRACE>'
                elif k in ('coupon_id','user_coupon_id','user_id'):
                    check(type(x) is int,'owned ID type');expected=ids[{'coupon_id':'coupon_id','user_coupon_id':'claim_id','user_id':'actor_id'}[k]];check(x==expected,'owned ID belongs to source');v[k]='<OWNED_'+k+'>'
                else:walk(x)
        elif isinstance(v,list):
            for x in v:walk(x)
    walk(value);return value
def load(root):
    return {n:json.loads((root/(n+'.json')).read_text()) for n in ('report','responses','fixture_map','source-private')}
def compare_loaded(b,c):
    result={'status':'pass','methods':[{'method':'GET','path':'/api/Coupon/list'},{'method':'GET','path':'/api/MyCoupon/list'},{'method':'POST','path':'/api/MyCoupon/receive'}],'pairs':[],'lanes':{},'limits':['Coupon/list natural pages differ; only owned complete row compared','explicit trace_id volatile mapping; original trace private','parameter-bound CLOB/HTTP proof only; not SQL literal/bulk migration','four-byte charset unresolved and not exercised','temporary three positive IDs are field compatibility, not issuance business','original frozen batch cleaned before later retain-fixtures policy']}
    for lane,x in [('B',b),('C',c)]:
        r=x['report'];check(r['lane']==lane and r['unexpected'] is None,'clean execution '+lane)
        check([q['case'] for q in r['cases']]==CASES,'all 16 ordered cases '+lane)
        check(all(q['source_pass'] and q['trace_contract'] and q['http_status']==200 for q in r['cases']),'independent source readback '+lane)
        check(r['natural_unchanged'] and r['actors_unchanged'] and r['before']==r['after'],'natural/actor unchanged '+lane)
        check(r['cleanup']=={'user_coupon':1,'coupon':1} and r['independent_remaining']=={'coupon':0,'user_coupon':0} and r['token_cleaned'],'cleanup '+lane)
        check(r['external_attempts']==0,'external zero '+lane);check(r['source_sha256']['app/api/model/Coupon.php']==COUPON_SHA,'locked source '+lane)
        ids=x['fixture_map'];check(all(type(ids[k]) is int and ids[k]>0 for k in ('coupon_id','claim_id','actor_id')),'positive original owned ids '+lane)
        events=r['events'];queries=[e for e in events if e['kind']=='query_observed'];ends=[e for e in events if e['kind']=='request_end']
        check(len(ends)==16 and all(not e['fatal'] and e['http_status']==200 for e in ends),'all observed HTTP finishes '+lane)
        check(all(e['callsites'] and not e['fingerprint_lexical_warning'] for e in queries),'actual callsites '+lane)
        writes=[e for e in queries if e['operation'] in ('insert','update','delete','replace','merge')]
        check(collections.Counter(e['operation'] for e in writes)=={'insert':1,'update':1} and all(e['scenario']=='PILOT_CPN_RECEIVE' for e in writes),'receive only 2 HTTP writes '+lane)
        check(not any(e['scenario']=='PILOT_CPN_REPEAT' for e in writes),'repeat DML zero '+lane)
        text={};
        for case,length in [('CP_DEFAULT',None),('CP_JSON_QUOTES',44),('CP_JSON_32767',32767),('CP_JSON_32768',32768)]:
            fields=x['source-private'][case]['text_fields'];text[case]=fields
            for name,v in fields.items():
                check(v['bytes']==length and v['type']==('NULL' if length is None else 'string'),'source CLOB type/bytes '+case)
                owned=x['responses'][case]['owned_row'][name]
                check(owned is None if length is None else type(owned) is str and len(owned.encode())==length and hashlib.sha256(owned.encode()).hexdigest()==v['sha256'],'wire CLOB exact '+case)
        metadata=[e for e in queries if e['fingerprint'] in META];business=[e for e in queries if e['fingerprint'] not in META]
        result['lanes'][lane]={'cases':16,'source_pass':16,'natural_before':r['before'],'natural_after':r['after'],'actors_unchanged':True,'cleanup':r['cleanup'],'independent_remaining':r['independent_remaining'],'token_cleaned':True,'external_attempts':0,'http_queries':len(queries),'metadata_queries':len(metadata),'business_queries':len(business),'http_writes':dict(collections.Counter(e['operation'] for e in writes)),'source_sha256':r['source_sha256'],'text_field_hashes':text}
    for name in CASES:
        br=b['responses'][name];cr=c['responses'][name];strict(br['http_status'],cr['http_status']);strict(br['method'],cr['method']);strict(br['path'],cr['path'])
        if name.startswith('CP_'):
            # Do not discard/normalize natural records into an equal-page claim.
            check(br['body']['status']==cr['body']['status']==200,'coupon status')
            check(type(br['body']['data']['list']) is list and type(cr['body']['data']['list']) is list,'coupon array wire')
            if name=='CP_CLEANED':scope='owned_absence_and_independent_natural_snapshot';equal=None
            else:
                strict(normalized(br['owned_row'],b['fixture_map']),normalized(cr['owned_row'],c['fixture_map']))
                strict(br['owned_row'],b['source-private'][name]['expected_wire']);strict(cr['owned_row'],c['source-private'][name]['expected_wire']);scope='owned_full_row_strict';equal=True
                check(br['natural_count']==9 and cr['natural_count']==0,'natural active pages explicitly different')
        else:
            strict(normalized(br['body'],b['fixture_map']),normalized(cr['body'],c['fixture_map']));scope='full_response_owned_ID_and_trace_mapping';equal=True
        result['pairs'].append({'case':name,'method':br['method'],'path':br['path'],'pass':True,'scope':scope,'strict_equal':equal})
    strict(result['lanes']['B']['text_field_hashes'],result['lanes']['C']['text_field_hashes'])
    result.update(pair_count=16,pair_pass=16,strict_full_response_pairs=10,strict_owned_row_pairs=5,cleanup_presence_pairs=1,business_code_changes_in_this_batch=0,ddl_in_this_batch=0)
    return result
if __name__=='__main__':
    try:print(json.dumps(compare_loaded(load(pathlib.Path(sys.argv[1])),load(pathlib.Path(sys.argv[2]))),ensure_ascii=False,indent=2))
    except Exception as e:print(json.dumps({'status':'fail','class':type(e).__name__,'reason':str(e)}));raise SystemExit(1)
