"""Create/verify runtime-neutral offline mapping evidence; never installs hooks."""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path
import re


def audit_module():
    path = Path(__file__).with_name('Audit-GalaxyClientCompatibility.py')
    spec = importlib.util.spec_from_file_location('galaxy_pe_audit', path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def internal_branches(module, old, new, before, after, length):
    a, b = old.instructions(before, length), new.instructions(after, length)
    if len(a) != len(b):
        raise ValueError('Instruction boundaries changed')
    count = 0
    for left, right in zip(a, b):
        if left.size != right.size or left.mnemonic != right.mnemonic:
            raise ValueError('Instruction layout changed')
        if not (left.group(module.cs.CS_GRP_JUMP) or left.group(module.cs.CS_GRP_CALL)):
            continue
        for x, y in zip(left.operands, right.operands):
            if x.type == y.type == module.X86_OP_IMM and old.base + before <= x.imm < old.base + before + length:
                if x.imm - old.base - before != y.imm - new.base - after:
                    raise ValueError('Internal control-flow target changed')
                count += 1
    return count


def code_record(module, old, new, before, after, method, sources=None, limit=384):
    signature = module.signature(old, before, limit)
    if not signature:
        raise ValueError(f'No signature for {before:x}')
    raw, mask, *_ = signature
    actual = new.data(after, len(raw))
    if len(actual) != len(raw) or any(a != b for a, b, keep in zip(raw, actual, mask) if keep):
        raise ValueError(f'Code signature mismatch {before:x}->{after:x}')
    branches = internal_branches(module, old, new, before, after, len(raw))
    return {'oldRva': hex(before), 'newRva': hex(after), 'kind': 'code', 'method': method,
            'sources': sources or [], 'bytes': len(raw),
            'internalBranchesVerified': branches,
            'maskHex': mask.hex(), 'oldBytesSha256': hashlib.sha256(raw).hexdigest(),
            'newBytesSha256': hashlib.sha256(actual).hexdigest()}


def data_record(old, new, before, after, method, sources=None):
    section = old.pe.get_section_by_rva(before)
    target = new.pe.get_section_by_rva(after)
    if not section or not target or section.Name != target.Name:
        raise ValueError(f'Data section mismatch {before:x}->{after:x}')
    initialised = before - section.VirtualAddress < section.SizeOfRawData
    new_initialised = after - target.VirtualAddress < target.SizeOfRawData
    length = 16 if initialised and new_initialised else 0
    return {'oldRva': hex(before), 'newRva': hex(after), 'kind': 'data', 'method': method,
            'sources': sources or [], 'section': section.Name.rstrip(b'\0').decode(),
            'storage': 'file-backed' if length else 'zero-fill-runtime-value-unknown',
            'bytes': length, 'oldBytesSha256': hashlib.sha256(old.data(before, length)).hexdigest(),
            'newBytesSha256': hashlib.sha256(new.data(after, length)).hexdigest()}


def manual_code_record(module, old, new, row):
    before, after, length = int(row['oldRva'], 16), int(row['newRva'], 16), row['bytes']
    raw, actual = old.data(before, length), new.data(after, length)
    sig = module.signature(old, before, length)
    mask = bytearray(sig[1]) + bytearray(b'\xff' * (length - len(sig[1])))
    for field in row['extraAddressFields']:
        at, width = field['offset'], field.get('width', 4)
        if int.from_bytes(raw[at:at + width], 'little') != int(field['oldValue'], 16) or \
           int.from_bytes(actual[at:at + width], 'little') != int(field['newValue'], 16):
            raise ValueError('Manual address field changed')
        mask[at:at + width] = b'\0' * width
    if len(raw) != length or len(actual) != length or any(a != b for a, b, keep in zip(raw, actual, mask) if keep):
        raise ValueError('Manual body has unexplained differences')
    return {'oldRva': row['oldRva'], 'newRva': row['newRva'], 'kind': 'code',
            'method': row.get('method', 'manually-reviewed-embedded-address-fields'), 'evidence': row['basis'],
            'sources': row.get('sources', []), 'bytes': length, 'maskHex': mask.hex(),
            'oldBytesSha256': hashlib.sha256(raw).hexdigest(), 'newBytesSha256': hashlib.sha256(actual).hexdigest()}


def published_galaxy_bindings(module, old, new):
    """Recheck later read-only adapters against their original paired bodies.

    These small sidecars extend the original source inventory. In particular,
    zero-filled globals are established by instruction operands, not by equal
    zero bytes or a presumed image-wide displacement.
    """
    root = Path('artifacts/galaxy-query-research')
    paths = [root / 'power-outer-sources/compatibility.json',
             root / 'thargoid-account-sources/compatibility.json',
             root / 'power-outer-sources/territory-constants.json',
             root / 'fleet-mission-sources/compatibility.json',
             root / 'fleet-mission-sources/missions-compatibility.json',
             root / 'commodity-tree/compatibility.json',
             root / 'account-sources/current-trade-compatibility.json',
             root / 'constellations/quality-provider-compatibility.json',
             Path('artifacts/galaxy-client-compatibility/local-docking-slot-closure.json'),
             root / 'constellations/catalogue-profile-compatibility.json',
             root / 'allegiance-definitions/compatibility.json',
             Path('artifacts/galaxy-client-compatibility/community-goal-optin-compatibility.json'),
             root / 'navroute-membership/compatibility.json',
             root / 'route-settings/compatibility.json',
             root / 'point-overrides/compatibility.json',
             root / 'route-filter/compatibility.json',
             root / 'civilization/compatibility.json',
             root / 'service-facts/compatibility.json',
             root / 'carrier-facts/compatibility.json',
             root / 'power-outer-sources/background/dust-runtime/compatibility.json',
             root / 'route-checkbox-filters/compatibility.json',
             root / 'power-outer-extent/compatibility.json',
             root / 'state-facts/compatibility.json',
             Path('artifacts/galaxy-client-compatibility/docking-zero-update-compatibility.json'),
             root / 'colonisation-colour/compatibility.json',
             root / 'colonisation-highlight/compatibility.json',
             root / 'power-influence/compatibility.json']
    proofs = [json.loads(path.read_text(encoding='utf8')) for path in paths]
    old_hash, new_hash = (hashlib.sha256(image.raw).hexdigest() for image in (old, new))
    records, references = {}, []
    for proof, path in zip(proofs, paths):
        route_schema=path==paths[20]
        source_hash_schema=route_schema or path in (paths[24],paths[25],paths[26])
        if source_hash_schema:
            if proof.get('gameProcessAccess') is not False or proof.get('runtimeValidated') is not False or \
               proof.get('sourceHashes')!=[old_hash,new_hash]:
                raise ValueError('Original route-mask proof scope/images changed')
        elif proof.get('runtimeNeutral') is not True:
            raise ValueError('Published binding proof is not runtime-neutral')
        for prefix, expected in (('old', old_hash), ('new', new_hash)):
            actual = proof['sourceHashes'][0 if prefix=='old' else 1] if source_hash_schema else proof.get(prefix + 'ImageSha256', proof.get(prefix + 'Image', {}).get('sha256'))
            if actual != expected:
                raise ValueError('Published binding proof image mismatch: ' + path.name)
        for row in proof['functions']:
            if row['status'] != 'unique':
                raise ValueError('Published binding function remains ambiguous')
            before = int(row.get('oldRva', row.get('old', row.get('oldFunctionRange', [''])[0])), 16)
            after = int(row.get('newRva', row.get('new', '')), 16)
            length = row.get('length', row.get('bytes'))
            original_function=old.function
            if route_schema and row.get('boundedLeafReason'):
                # Its absolute indexed jump table is inside this explicitly
                # bounded switch leaf. The general no-unwind96B fallback is
                # insufficient; validate through the unique physical RET.
                ins=old.instructions(before,length)
                returns=[i for i in ins if i.group(module.cs.CS_GRP_RET)]
                if original_function(before) is not None or len(returns)!=1 or returns[0].address+returns[0].size-old.base!=before+length:
                    raise ValueError('Original route switch leaf bound changed')
                old.function=lambda rva,fn=original_function,start=before,end=before+length:(start,end,0) if start<=rva<end else fn(rva)
            try:
                records[hex(before)] = code_record(module, old, new, before, after,
                    'published-adapter-full-body-and-operand-binding', [str(path)], limit=length)
            finally:old.function=original_function
            for table in row.get('actualSwitchTables',[]):
                expected=table['relativeTargets']
                if len(expected)!=16:raise ValueError('Published original classification switch size changed')
                for image,prefix,start in ((old,'old',before),(new,'new',after)):
                    target=int(table[prefix+'Table'],16)
                    actual=[int.from_bytes(image.data(target+index*4,4),'little')-start for index in range(16)]
                    if actual!=expected or any(offset<0 or offset>=length for offset in actual):
                        raise ValueError('Published original classification switch targets changed')
            if row.get('rawLeafWholeBody'):
                # A leaf may return early without PE unwind metadata. Validate
                # its complete bounded body, including all later hash buckets,
                # rather than recording only the first return's signature.
                raw, actual = old.data(before,length),new.data(after,length)
                if raw!=actual or hashlib.sha256(raw).hexdigest()!=row['oldWindowSha256'] or hashlib.sha256(actual).hexdigest()!=row['newWindowSha256']:
                    raise ValueError('Published full multi-return leaf changed')
                records[hex(before)].update(bytes=length,maskHex='ff'*length,
                    oldBytesSha256=hashlib.sha256(raw).hexdigest(),newBytesSha256=hashlib.sha256(actual).hexdigest(),
                    method='published-typed-slot-complete-identical-multi-return-leaf',
                    internalBranchesVerified=internal_branches(module,old,new,before,after,length))
            references.extend(module.references(old, new, before, after, length))

    outer, thargoid, territory, fleet, missions, commodities, tradeflows, quality, docking, catalogue_profile, allegiance, community_goal, navroute, route_settings, point_overrides, route_filter, civilization, service_facts, carrier_facts, dust_runtime, route_masks, outer_extent, state_facts, docking_zero_update, colonisation_colour, colonisation_highlight, power_influence = proofs
    # The route-mask schema calls its actual typed-slot rows `vtables` and
    # uses oldTarget/newTarget. Preserve and validate those actual slots;
    # they are not a list of presumed base-vtable addresses.
    route_slots=[dict(oldVtable=row['oldVtable'],newVtable=row['newVtable'],slot=row['slot'],oldRva=row['oldTarget'],newRva=row['newTarget']) for row in route_masks['vtables']]
    route_typed=dict(slots=route_slots,vtables={row['oldVtable']:row['newVtable'] for row in route_masks['providers']})
    dispatch=next(row for row in route_masks['functions'] if int(row['oldRva'],16)==0x3B16B00)
    dispatch_targets=[]
    for image,start in ((old,0x3B16B00),(new,int(dispatch['newRva'],16))):
        candidates=[operand for ins in image.instructions(start,64) for operand in ins.operands if operand.type==module.X86_OP_MEM and operand.mem.index!=0]
        if len(candidates)!=1:raise ValueError('Original route category dispatch table is ambiguous')
        targets=[int.from_bytes(image.data(candidates[0].mem.disp+index*4,4),'little') for index in range(16)]
        if any(target<start or target>=start+dispatch['length'] for target in targets):raise ValueError('Original route category target leaves its verified leaf')
        dispatch_targets.append([target-start for target in targets])
    if dispatch_targets[0]!=dispatch_targets[1]:raise ValueError('Original route category jump targets changed')
    for row in outer['navigationGetters']:
        before, after, length = int(row['oldRva'], 16), int(row['newRva'], 16), row['bytes']
        for image, prefix, expected in ((old, 'old', before), (new, 'new', after)):
            actual = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16) + int(row['slot'], 16), 8), 'little') - image.base
            if actual != expected:
                raise ValueError('Published navigation getter slot changed')
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-original-navigation-vtable-getter', [str(paths[0])], limit=length)

    def bound_data(before, after, basis):
        pairs = [r for r in references if r[0] == before]
        if not pairs or {r[1] for r in pairs} != {after}:
            raise ValueError('Published data binding is not established by paired operands: ' + hex(before))
        record = data_record(old, new, before, after, 'published-ctor-or-getter-paired-operand', basis)
        record['evidence'] = [list(map(hex, row)) for row in pairs]
        records[hex(before)] = record

    for row in quality['getters']:
        before, after, offset = int(row['oldRva'],16), int(row['newRva'],16), int(row['slot'],16)
        for image, prefix, expected in ((old,'old',before),(new,'new',after)):
            actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
            if actual!=expected:raise ValueError('Published quality-provider getter slot changed')
        records[hex(before)]=code_record(module,old,new,before,after,
            'published-actual-quality-provider-getter',[str(paths[7])],limit=row['bytes'])
        references.extend(module.references(old,new,before,after,row['bytes']))
    for before, after in quality['vtables'].items():
        bound_data(int(before,16),int(after,16),[str(paths[7])])
    for before, after in quality['globals'].items():
        bound_data(int(before,16),int(after,16),[str(paths[7])])

    # The availability leaf newly referenced by LocalFlight is identified by
    # its actual typed docking interface, never a generic adjacent getter.
    for row in docking['slots']:
        before, after, offset = int(row['oldRva'],16), int(row['newRva'],16), int(row['slot'],16)
        for image, prefix, expected in ((old,'old',before),(new,'new',after)):
            actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
            if actual!=expected:raise ValueError('Published typed docking getter slot changed')
        records[hex(before)]=code_record(module,old,new,before,after,
            'published-original-typed-docking-interface-slot',[str(paths[8])],limit=row['bytes'])
        references.extend(module.references(old,new,before,after,row['bytes']))
    for group in ('vtables','globals'):
        for before, after in docking[group].items():
            bound_data(int(before,16),int(after,16),[str(paths[8])])

    for row in catalogue_profile['slots']:
        before, after, offset = int(row['oldRva'],16), int(row['newRva'],16), int(row['slot'],16)
        for image, prefix, expected in ((old,'old',before),(new,'new',after)):
            actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
            if actual!=expected:raise ValueError('Published catalogue profile getter slot changed')
        records[hex(before)]=code_record(module,old,new,before,after,
            'published-catalogue-renderer-original-field-getter',[str(paths[9])],limit=row['bytes'])
        references.extend(module.references(old,new,before,after,row['bytes']))
    for group in ('vtables','globals'):
        for before, after in catalogue_profile[group].items():
            bound_data(int(before,16),int(after,16),[str(paths[9])])

    for row in allegiance['slots']:
        before, after, offset = int(row['oldRva'],16), int(row['newRva'],16), int(row['slot'],16)
        for image, prefix, expected in ((old,'old',before),(new,'new',after)):
            actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
            if actual!=expected:raise ValueError('Published allegiance definition getter slot changed')
        if records.get(hex(before),{}).get('method')!='published-typed-slot-complete-identical-multi-return-leaf':
            records[hex(before)]=code_record(module,old,new,before,after,
                'published-allegiance-definition-typed-interface-slot',[str(paths[10])],limit=row['bytes'])
        references.extend(module.references(old,new,before,after,row['bytes']))
    for row in navroute['slots']:
        before, after, offset = int(row['oldRva'],16), int(row['newRva'],16), int(row['slot'],16)
        for image, prefix, expected in ((old,'old',before),(new,'new',after)):
            actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
            if actual!=expected:raise ValueError('Published NavRoute getter typed slot changed')
        records[hex(before)]=code_record(module,old,new,before,after,
            'published-navroute-actual-typed-slot',[str(paths[12])],limit=row['bytes'])
        references.extend(module.references(old,new,before,after,row['bytes']))
    binding=navroute['callBinding']
    for image,prefix in ((old,'old'),(new,'new')):
        actual=image.instructions(int(binding[prefix+'Call'],16),5)
        if len(actual)!=1 or actual[0].mnemonic!='call' or actual[0].operands[0].type!=module.X86_OP_IMM or actual[0].operands[0].imm-image.base!=int(binding[prefix+'Callee'],16):
            raise ValueError('Published NavRoute membership actual callee changed')
    for group in ('vtables','globals'):
        for before, after in allegiance[group].items():
            bound_data(int(before,16),int(after,16),[str(paths[10])])

    # These adapters add actual typed slots, including the small route type-ID
    # getter. Importing only their larger functions would leave those guarded
    # source addresses uninventoried and would not verify interface identity.
    for proof, path in ((route_settings,paths[13]),(point_overrides,paths[14]),(route_filter,paths[15]),
                        (civilization,paths[16]),(service_facts,paths[17]),(carrier_facts,paths[18]),
                        (dust_runtime,paths[19]),(route_typed,paths[20]),(outer_extent,paths[21]),(state_facts,paths[22]),(colonisation_colour,paths[24]),(colonisation_highlight,paths[25]),(power_influence,paths[26])):
        for row in proof.get('slots',[]):
            before,after,offset=int(row['oldRva'],16),int(row['newRva'],16),int(row['slot'],16)
            for image,prefix,expected in ((old,'old',before),(new,'new',after)):
                actual=int.from_bytes(image.data(int(row[prefix+'Vtable'],16)+offset,8),'little')-image.base
                if actual!=expected:raise ValueError('Published adapter typed slot changed: '+str(path))
            span=old.function(before)
            length=row.get('bytes',row.get('length')) or records.get(hex(before),{}).get('bytes')
            if length is None:
                if span is None or span[0]!=before:raise ValueError('Published route/point slot needs an exact body bound')
                length=span[1]-before
            records[hex(before)]=code_record(module,old,new,before,after,
                'published-adapter-actual-typed-slot',[str(path)],limit=length)
            if row.get('rawLeafWholeBody'):
                raw,actual=old.data(before,length),new.data(after,length)
                if raw!=actual:raise ValueError('Published complete typed leaf changed')
                records[hex(before)].update(bytes=length,maskHex='ff'*length,
                    oldBytesSha256=hashlib.sha256(raw).hexdigest(),newBytesSha256=hashlib.sha256(actual).hexdigest(),
                    method='published-actual-typed-slot-complete-identical-leaf',
                    internalBranchesVerified=internal_branches(module,old,new,before,after,length))
            references.extend(module.references(old,new,before,after,length))
        for group in ('vtables','globals'):
            bindings=proof.get(group,{})
            pairs=bindings.items() if isinstance(bindings,dict) else ((row['oldRva'],row['newRva']) for row in bindings)
            for before,after in pairs:
                bound_data(int(before,16),int(after,16),[str(path)])
    service_type=service_facts['typeGlobal']
    bound_data(int(service_type['oldRva'],16),int(service_type['newRva'],16),[str(paths[17])])

    for row in community_goal['callBindings']:
        before,after=int(row['oldFunction'],16),int(row['newFunction'],16)
        if records[hex(before)]['newRva']!=hex(after):raise ValueError('CG function binding changed')
        callee=records.get(row['oldCallee'])
        if not callee or callee['newRva']!=row['newCallee']:raise ValueError('CG independent send helper changed')
        if int(row['oldCall'],16)-before!=int(row['newCall'],16)-after:raise ValueError('CG call position changed')
        for image,prefix,function in ((old,'old',before),(new,'new',after)):
            at=int(row[prefix+'Call'],16);target=int(row[prefix+'Callee'],16)
            if not function<=at<function+records[hex(before)]['bytes']:raise ValueError('CG call is outside the paired body')
            ins=image.instructions(at,5)
            if len(ins)!=1 or ins[0].mnemonic!='call' or len(ins[0].operands)!=1 or ins[0].operands[0].type!=module.X86_OP_IMM or ins[0].operands[0].imm-image.base!=target:
                raise ValueError('CG independently proved direct call changed')
    request_type=community_goal['requestType']
    for image,prefix in ((old,'old'),(new,'new')):
        endpoint=(request_type['endpoint']+'\0').encode('utf8')
        if image.data(int(request_type[prefix+'Endpoint'],16),len(endpoint))!=endpoint:raise ValueError('CG opt-in endpoint changed')
        expected=b'\xba'+int(request_type['kind']).to_bytes(4,'little')
        if image.data(int(request_type[prefix+'KindInstruction'],16),5)!=expected:raise ValueError('CG constructor request kind changed')
    for field in ('Vtable','Endpoint'):
        bound_data(int(request_type['old'+field],16),int(request_type['new'+field],16),[str(paths[11])])

    for before in (0x55276E8, 0x5527718, 0x5527840, 0x55278C0):
        bound_data(before, int(thargoid['vtables'][hex(before)], 16), [str(paths[1])])
    for group, old_table in (('publishedInterfaceSlots', 0x55278C0),
                             ('sharedCommunityGoalInterfaceSlots', 0x5527840)):
        new_table = int(thargoid['vtables'][hex(old_table)], 16)
        for row in thargoid[group]:
            before, after, offset = int(row['oldRva'], 16), int(row['newRva'], 16), int(row['slot'], 16)
            for image, table, expected in ((old, old_table, before), (new, new_table, after)):
                actual = int.from_bytes(image.data(table + offset, 8), 'little') - image.base
                if actual != expected:
                    raise ValueError('Published account getter slot changed')
            records[hex(before)] = code_record(module, old, new, before, after,
                'published-account-interface-getter', [str(paths[1])], limit=row['bytes'])
            references.extend(module.references(old, new, before, after, row['bytes']))
    for address in (0x5F5428C, 0x5FA10F0):
        bound_data(address, address, [str(paths[1])])
    for key in ('overrideColourAlpha', 'overrideGeometry'):
        row = territory['fields'][key]
        bound_data(int(row['oldRva'], 16), int(row['newRva'], 16), [str(paths[2])])
    for row in fleet['slots']:
        before, after, offset = int(row['oldRva'], 16), int(row['newRva'], 16), int(row['slot'], 16)
        for image, prefix, expected in ((old, 'old', before), (new, 'new', after)):
            actual = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16) + offset, 8), 'little') - image.base
            if actual != expected:
                raise ValueError('Published Fleet getter slot changed')
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-fleet-interface-getter', [str(paths[3])], limit=row['bytes'])
        references.extend(module.references(old, new, before, after, row['bytes']))
    for before, after in fleet['vtables'].items():
        if before == '0x5294d08':
            # This table is independently closed by both original map-used
            # actor methods, rather than by a constructor in this sidecar.
            for offset, function in ((0x80, 0x2561450), (0xE0, 0x255A140)):
                paired = records[hex(function)]
                for image, table, method in ((old, int(before, 16), function),
                    (new, int(after, 16), int(paired['newRva'], 16))):
                    if int.from_bytes(image.data(table + offset, 8), 'little') - image.base != method:
                        raise ValueError('Published Fleet actor method binding changed')
            record = data_record(old, new, int(before, 16), int(after, 16),
                'published-map-actor-two-method-vtable-binding', [str(paths[3])])
            record['evidence'] = ['vfunc80:2561450', 'vfuncE0:255A140']
            records[before] = record
        else:
            bound_data(int(before, 16), int(after, 16), [str(paths[3])])
    for before, after in fleet['globals'].items():
        bound_data(int(before, 16), int(after, 16), [str(paths[3])])
    for row in missions['slots']:
        before, after, offset = int(row['oldRva'], 16), int(row['newRva'], 16), int(row['slot'], 16)
        for image, prefix, expected in ((old, 'old', before), (new, 'new', after)):
            actual = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16) + offset, 8), 'little') - image.base
            if actual != expected:
                raise ValueError('Published mission getter slot changed')
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-mission-interface-getter', [str(paths[4])], limit=row['bytes'])
        references.extend(module.references(old, new, before, after, row['bytes']))
    for before, after in missions['vtables'].items():
        bound_data(int(before, 16), int(after, 16), [str(paths[4])])
    for before, after in missions['globals'].items():
        bound_data(int(before, 16), int(after, 16), [str(paths[4])])
    for row in commodities['slots']:
        before, after, offset = int(row['oldRva'], 16), int(row['newRva'], 16), int(row['slot'], 16)
        for image, prefix, expected in ((old, 'old', before), (new, 'new', after)):
            actual = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16) + offset, 8), 'little') - image.base
            if actual != expected:
                raise ValueError('Published commodity getter slot changed')
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-commodity-interface-getter', [str(paths[5])], limit=row['bytes'])
        references.extend(module.references(old, new, before, after, row['bytes']))
    for row in commodities['getters']:
        before, after = int(row['oldRva'], 16), int(row['newRva'], 16)
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-commodity-name-and-category-getter', [str(paths[5])], limit=row['bytes'])
        references.extend(module.references(old, new, before, after, row['bytes']))
    for group in ('vtables', 'globals'):
        for before, after in commodities[group].items():
            bound_data(int(before, 16), int(after, 16), [str(paths[5])])
    for row in tradeflows['slots']:
        before, after, offset = int(row['oldRva'], 16), int(row['newRva'], 16), int(row['slot'], 16)
        for image, prefix, expected in ((old, 'old', before), (new, 'new', after)):
            actual = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16) + offset, 8), 'little') - image.base
            if actual != expected:
                raise ValueError('Published current trade getter slot changed')
        records[hex(before)] = code_record(module, old, new, before, after,
            'published-current-trade-interface-getter', [str(paths[6])], limit=row['bytes'])
        references.extend(module.references(old, new, before, after, row['bytes']))
    for group in ('vtables', 'globals'):
        for before, after in tradeflows[group].items():
            bound_data(int(before, 16), int(after, 16), [str(paths[6])])
    artifacts = [{'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in paths]
    ring_path,ring_dependencies=verify_original_ring_geometry(old)
    artifacts.extend({'path':str(path),'sha256':hashlib.sha256(path.read_bytes()).hexdigest()} for path in [ring_path,*ring_dependencies])
    return records, artifacts


def verify_original_ring_geometry(old):
    """Pinned geometry/shader proof is not an ED5 Native address mapping."""
    root=Path('artifacts/galaxy-query-research/power-outer-sources')
    path=root/'focus-ring/original-ring.json'
    proof=json.loads(path.read_text(encoding='utf8'))
    if proof.get('gameProcessAccess') is not False or proof.get('gameCalls')!=0 or proof.get('networkRequests')!=0 or \
       proof['imageSha256']!=hashlib.sha256(old.raw).hexdigest():raise ValueError('Original range geometry scope/image changed')
    for row in proof['functions']:
        if hashlib.sha256(old.data(int(row['rva'],16),row['length'])).hexdigest()!=row['sha256']:
            raise ValueError('Original range geometry body changed')
    if proof.get('primitive')!='TriangleStrip' or proof.get('primitiveCount')!=32 or proof.get('vertexCount')!=34 or \
       old.data(0x4E2DE48+5*2,2)!=b'\x01\x02' or int.from_bytes(old.data(0x4E2DE78+5*4,4),'little')!=5:
        raise ValueError('Original range topology/count proof changed')
    import struct
    def floats(at,count):return list(struct.unpack('<'+'f'*count,old.data(at,count*4)))
    constants=dict(distanceMaximum=floats(0x4E29750,1)[0],distanceMinimum=floats(0x4E328C0,1)[0],widthScale=floats(0x552F4E8,1)[0],
                   uAxis=floats(0x50CB0D0,3),vAxis=floats(0x50CB0F0,3),colour=floats(0x54C8330,4))
    if constants!=proof['constants']:raise ValueError('Original range geometry constants changed')
    material_path=root/'material/original-materials.json';blend_path=root/'material-slots/original-blend-application.json'
    material=next(row for row in json.loads(material_path.read_text())['materials'] if row['name']=='focusring')
    state=next(row for row in json.loads(blend_path.read_text())['materials'] if row['material']=='focusring')
    if material!=proof['material'] or not state['resolved'] or state['originalEnums']!=proof['sourceBlend']:
        raise ValueError('Original range material/state changed')
    dependencies=[material_path,blend_path]
    for stage,expected in proof['shaderSha256'].items():
        shader=root/f'effects/{stage}_GalaxyMapFocusRing_Forward_0_Win64_SM50.dxbc';dependencies.append(shader)
        if hashlib.sha256(shader.read_bytes()).hexdigest()!=expected:raise ValueError('Original range shader changed')
    if proof['exposure'].get('runtimeResolved') is not False or proof['exposure'].get('unknownMustRemainHidden') is not True:
        raise ValueError('Original range unresolved exposure must remain explicit')
    return path,dependencies


def build(audit_path, output):
    module = audit_module()
    report = json.loads(audit_path.read_text(encoding='utf8'))
    old = module.Image(Path(report['old']['path']))
    new = module.Image(Path(report['new']['path']))
    mappings, unresolved = {}, []
    for key, value in report['functions'].items():
        if 'source' not in value:
            continue
        if value['status'] == 'unique':
            mappings[key] = code_record(module, old, new, int(key, 16), int(value['newRva'], 16),
                                        'intrinsic-unique-bounded-signature', value['source'])
        else:
            unresolved.append({'oldRva': key, 'kind': 'code', 'status': value['status'], 'sources': value['source']})
    contextual = {x['oldRva']: x for x in report['contextualFunctionCandidates']}
    for value in list(unresolved):
        candidate = contextual.get(value['oldRva'])
        if not candidate or candidate['status'] != 'single-context-candidate':
            continue
        after = int(candidate['candidates'][0]['newRva'], 16)
        mappings[value['oldRva']] = code_record(module, old, new, int(value['oldRva'], 16), after,
                                               'single-reference-context-plus-byte-signature', value['sources'])
        mappings[value['oldRva']]['evidence'] = candidate['candidates'][0]['evidence']
        unresolved.remove(value)
    for value in report['dataReferences']:
        if value['status'] == 'consistent-reference':
            after = int(value['candidates'][0]['newRva'], 16)
            mappings[value['oldRva']] = data_record(old, new, int(value['oldRva'], 16), after,
                                                   'consistent-paired-reference', value['source'])
            mappings[value['oldRva']]['evidence'] = value['candidates'][0]['references']
        else:
            unresolved.append({'oldRva': value['oldRva'], 'kind': 'data', 'status': value['status'], 'sources': value['source']})
    manual_path = audit_path.with_name('manual-closures.json')
    manual = json.loads(manual_path.read_text(encoding='utf8')) if manual_path.exists() else {'code': [], 'data': []}
    for value in manual['code']:
        mappings[value['oldRva']] = manual_code_record(module, old, new, value)
        unresolved = [x for x in unresolved if x['oldRva'] != value['oldRva']]
    for value in manual['data']:
        mappings[value['oldRva']] = data_record(old, new, int(value['oldRva'], 16), int(value['newRva'], 16),
                                               'explicit-body-proven-array-or-data-binding', value.get('sources'))
        mappings[value['oldRva']]['evidence'] = value['basis']
        unresolved = [x for x in unresolved if x['oldRva'] != value['oldRva']]
    foundation_path = audit_path.with_name('galaxy-foundation-slots.json')
    foundations = json.loads(foundation_path.read_text(encoding='utf8'))
    for value in foundations:
        if not value['normalisedBytesMatch']:
            raise ValueError('An expected foundation slot did not match')
        before, after = int(value['oldFunction'], 16), int(value['newFunction'], 16)
        mappings.setdefault(hex(before), code_record(module, old, new, before, after, 'explicit-foundation-vtable-slot'))
        unresolved = [x for x in unresolved if x['oldRva'] != hex(before)]
    supporting = []
    for name in ('manual-closures.json', 'galaxy-registry-closure.json', 'galaxy-component-bindings.json',
                 'csm-gameplay-string-closure.json', 'dynamics-slot-closure.json',
                 'appearance-manual-triage.json', 'radius-getter-manual-triage.json',
                 'research-switch-table-closures.json', 'non-galaxy-context-closures.json',
                 'commerce-ctor-1e84fc0-closure.json', 'commerce-ctor-1e85ee0-closure.json',
                 'station-send-callers.json'):
        path = audit_path.with_name(name)
        if path.exists():
            supporting.append({'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()})
    csm = json.loads(audit_path.with_name('csm-gameplay-string-closure.json').read_text(encoding='utf8'))
    registry = json.loads(audit_path.with_name('galaxy-registry-closure.json').read_text(encoding='utf8'))
    registrations = json.loads(audit_path.with_name('galaxy-component-bindings.json').read_text(encoding='utf8'))
    for row in registry['functions']:
        if row['status'] != 'unique':
            raise ValueError('Registry basis remains ambiguous')
        before, after = int(row['oldRva'], 16), int(row['newRva'], 16)
        mappings.setdefault(row['oldRva'], code_record(module, old, new, before, after, 'registry-foundation-byte-match'))
    before, after = int(csm['oldFunction'], 16), int(csm['newFunction'], 16)
    mappings[csm['oldFunction']] = code_record(module, old, new, before, after,
                                             'full-csm-predicate-plus-literal-closure', limit=csm['contiguousSpanLength'])
    published, published_artifacts = published_galaxy_bindings(module, old, new)
    mappings.update(published)
    supporting.extend(published_artifacts)
    profile = {
        'format': 'galaxy-offline-mapping-evidence-v1',
        'runtimeNeutral': True, 'canInstallHooks': False, 'runtimeValidated': False,
        'oldImage': {'path': str(old.path), 'sha256': hashlib.sha256(old.raw).hexdigest()},
        'newImage': {'path': str(new.path), 'sha256': hashlib.sha256(new.raw).hexdigest()},
        'audit': str(audit_path), 'auditSha256': hashlib.sha256(audit_path.read_bytes()).hexdigest(),
        'mappings': list(mappings.values()),
        'foundationSlots': foundations, 'componentBindings': registrations['bindings'],
        'csmStrings': csm['strings'], 'unresolved': unresolved,
        'supportingArtifacts': supporting,
        'requiredManualChecks': [
            'Native calling conventions, trampoline relocation and owning-thread behavior are not validated by this file.',
            'Address candidates do not prove account/server cache readiness, source completeness or new-version live coverage.',
            'Copied object layouts require the original constructor/getter/decoder evidence; byte windows alone do not approve them.',
            'Only a separately reviewed exact-hash native profile may be used; this evidence ledger does not approve installation.'
        ]
    }
    output.write_text(json.dumps(profile, ensure_ascii=False, indent=2), encoding='utf8')
    return profile


def verify(profile_path, old_path=None, new_path=None):
    module = audit_module()
    profile = json.loads(profile_path.read_text(encoding='utf8'))
    if profile.get('runtimeNeutral') is not True or profile.get('canInstallHooks') is not False or profile.get('runtimeValidated') is not False:
        raise ValueError('Evidence file must remain explicitly runtime-neutral')
    audit_bytes = Path(profile['audit']).read_bytes()
    if hashlib.sha256(audit_bytes).hexdigest() != profile['auditSha256']:
        raise ValueError('Underlying reference audit changed')
    report = json.loads(audit_bytes)
    expected = {key: row['newRva'] for key, row in report['functions'].items() if row['status'] == 'unique'}
    expected.update({row['oldRva']: row['candidates'][0]['newRva'] for row in report['dataReferences']
                     if row['status'] == 'consistent-reference'})
    for row in report['contextualFunctionCandidates']:
        if row['status'] == 'single-context-candidate':
            expected.setdefault(row['oldRva'], row['candidates'][0]['newRva'])
    for artifact in profile['supportingArtifacts']:
        path = Path(artifact['path'])
        raw = path.read_bytes()
        if hashlib.sha256(raw).hexdigest() != artifact['sha256']:
            raise ValueError('Supporting evidence changed: ' + path.name)
        if path.name == 'manual-closures.json':
            manual = json.loads(raw)
            for row in manual['code'] + manual['data']:
                expected[row['oldRva']] = row['newRva']
    old = module.Image(old_path or Path(profile['oldImage']['path']))
    new = module.Image(new_path or Path(profile['newImage']['path']))
    for label, image in (('oldImage', old), ('newImage', new)):
        if hashlib.sha256(image.raw).hexdigest() != profile[label]['sha256']:
            raise ValueError(label + ' hash mismatch')
    published, _ = published_galaxy_bindings(module, old, new)
    expected.update({key: row['newRva'] for key, row in published.items()})
    seen = set()
    for row in profile['mappings']:
        if row['oldRva'] in seen:
            raise ValueError('Duplicate old address')
        seen.add(row['oldRva'])
        if row['oldRva'] in expected and expected[row['oldRva']] != row['newRva']:
            raise ValueError('Address disagrees with reference evidence: ' + row['oldRva'])
        before, after = int(row['oldRva'], 16), int(row['newRva'], 16)
        length = row['bytes']
        a, b = old.data(before, length), new.data(after, length)
        if hashlib.sha256(a).hexdigest() != row['oldBytesSha256'] or hashlib.sha256(b).hexdigest() != row['newBytesSha256']:
            raise ValueError('Recorded bytes changed at ' + row['oldRva'])
        if row['kind'] == 'code':
            mask = bytes.fromhex(row['maskHex'])
            if len(mask) != length or any(x != y for x, y, keep in zip(a, b, mask) if keep):
                raise ValueError('Normalized code changed at ' + row['oldRva'])
            if 'internalBranchesVerified' in row and internal_branches(module, old, new, before, after, length) != row['internalBranchesVerified']:
                raise ValueError('Internal branch verification changed')
        elif old.section(before) != new.section(after) or old.section(before) != row['section']:
            raise ValueError('Data section changed')
    for slot in profile['foundationSlots']:
        offset = int(slot['slot'], 16)
        before = int.from_bytes(old.data(int(slot['oldVtable'], 16) + offset, 8), 'little') - old.base
        after = int.from_bytes(new.data(int(slot['newVtable'], 16) + offset, 8), 'little') - new.base
        if before != int(slot['oldFunction'], 16) or after != int(slot['newFunction'], 16):
            raise ValueError('Foundation slot changed: ' + slot['name'])
    for row in profile['csmStrings']:
        a = old.data(int(row['oldRva'], 16), 256).split(b'\0')[0]
        b = new.data(int(row['newRva'], 16), 256).split(b'\0')[0]
        if a != b or a.decode('ascii') != row['oldText'] or b.decode('ascii') != row['newText']:
            raise ValueError('CSM state literal changed')
    for row in profile['componentBindings']:
        for image, prefix in ((old, 'old'), (new, 'new')):
            function = int.from_bytes(image.data(int(row[prefix + 'Vtable'], 16), 8), 'little') - image.base
            if function != int(row[prefix + 'TypeGetter'], 16):
                raise ValueError('Registration type-getter slot changed')
            instruction = image.instructions(function, 6)[0]
            memory = [op for op in instruction.operands if op.type == module.X86_OP_MEM and op.mem.base == module.X86_REG_RIP]
            if instruction.mnemonic != 'mov' or len(memory) != 1 or \
               instruction.address + instruction.size + memory[0].mem.disp - image.base != int(row[prefix + 'TypeIdGlobal'], 16):
                raise ValueError('Registration type-ID global changed')
    inventory = module.source_inventory()
    state_source = Path('native/ManagedPlugin/Navigation.State.cs').read_text(encoding='utf8')
    source_hash = re.search(r'GameHash\s*=\s*"([\da-fA-F]{64})"', state_source).group(1).lower()
    unresolved_uses = []
    if source_hash == profile['oldImage']['sha256']:
        source_version = 'old'
        accounted = {int(x['oldRva'], 16) for x in profile['mappings'] + profile['unresolved']}
    elif source_hash == profile['newImage']['sha256']:
        source_version = 'new'
        accounted = {int(x['newRva'], 16) for x in profile['mappings']}
        for row in profile['unresolved']:
            candidates = report['functions'].get(row['oldRva'], {}).get('candidates', [])
            for candidate in candidates:
                address = int(candidate, 16)
                if address in inventory:
                    accounted.add(address)
                    unresolved_uses.append({'oldRva': row['oldRva'], 'sourceNewRva': candidate,
                                            'status': 'source-uses-unresolved-context-candidate', 'sources': row['sources']})
    else:
        raise ValueError('Current source exact-hash profile is outside these audited images')
    missing = set(inventory) - accounted
    if missing:
        raise ValueError('Current source has uninventoried addresses: ' + ','.join(hex(x) for x in sorted(missing)))
    return {'verifiedMappings': len(profile['mappings']), 'verifiedFoundationSlots': len(profile['foundationSlots']),
            'verifiedRegistrationBindings': len(profile['componentBindings']),
            'verifiedCsmStrings': len(profile['csmStrings']), 'sourceRvasAccounted': len(inventory),
            'sourceImageProfile': source_version, 'sourceUnresolvedUses': unresolved_uses,
            'unresolved': len(profile['unresolved']),
            'runtimeValidated': False, 'canInstallHooks': False}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--audit', type=Path, default=Path('artifacts/galaxy-client-compatibility/audit-closure.json'))
    parser.add_argument('--output', type=Path, default=Path('artifacts/galaxy-client-compatibility/galaxy-mapping.json'))
    parser.add_argument('--verify', type=Path)
    args = parser.parse_args()
    if args.verify:
        result = verify(args.verify)
    else:
        build(args.audit, args.output)
        result = verify(args.output)
    print(json.dumps(result, indent=2))


if __name__ == '__main__':
    main()
