"""Normalize source-declared current objective operands without choosing input.

`ready` means this normalization is complete, not that a receiver exists, an
action is safe to send, or an effect happened. Raw values and table domains
remain attached; GroupTag, QuestFakeItem and inventory are never interchangeable.
"""
from copy import deepcopy
from datetime import datetime
import math


SCOPE = ('quest_id','instance_id','accept_time','step_id','step_index','objective_index','process_started_at')
_SINGLE_DELIVERY = {
    'DeliverConsumablesToNPCQuestObjectiveData': ('ConsumableName','consumable'),
    'DeliverEquipmentsToNPCQuestObjectiveData': ('EquipmentName','equipment'),
    'DeliverFakeItemsToNPCQuestObjectiveData': ('FakeItemSheetId','quest_fake_item'),
    'DeliverCostumesToNPCQuestObjectiveData': ('CostumeName','costume'),
    'DeliverRidableToNPCQuestObjectiveData': ('RidableName','ridable'),
}
_MULTIPLE_DELIVERY = {
    'DeliverMultipleConsumablesToNPCQuestObjectiveData': 'ConsumableNames',
    'DeliverMultipleFakeItemsToNPCQuestObjectiveData': 'FakeItems',
    'DeliverMultipleCostumesToNPCQuestObjectiveData': 'CostumeNames',
    'DeliverMultipleItemsToNPCQuestObjectiveData': 'ItemList',
    'DeliverMultipleItemsToGroupTagNPCQuestObjectiveData': 'ItemList',
}
_ITEM_ROWS = {
    'ConsumableAmountData': ('ConsumableName','consumable'),
    'FakeItemAmountData': ('FakeItemSheetId','quest_fake_item'),
    'CostumeAmountData': ('CostumeName','costume'),
    'ConsumableAmountPairItemAmountPairData': ('ConsumableName','consumable'),
    'EquipmentAmountPairItemAmountPairData': ('EquipmentName','equipment'),
    'FakeItemAmountPairItemAmountPairData': ('FakeItemSheetId','quest_fake_item'),
    'CostumeAmountPairItemAmountPairData': ('CostumeName','costume'),
}
_KINDS = {'NPCSheet':'npc','ChannelSheet':'channel','InstantPropSheet':'instant_prop',
    'GatherableSheet':'gatherable','ConsumableSheet':'consumable','EquipmentSheet':'equipment',
    'QuestFakeItemSheet':'quest_fake_item','CostumeSheet':'costume','RidableSheet':'ridable'}


def _full(t):
    return t.get('namespace','')+'.'+t.get('name','')


def _label(name):
    return name[1:name.index('>')] if name.startswith('<') and '>' in name else name


def collect_operand_missing(value, path=''):
    """Include errors inside references/lists, even if a container says parsed.

    Paths use JSON-pointer notation. Existing producer messages are retained.
    The bounded traversal also treats an unavailable/uninterpreted leaf with
    no diagnostic as a gap. Metadata declarations and raw payloads are not
    recursively interpreted as observations.
    """
    issues=[];seen=set();budget=[0]
    def visit(row,at,depth):
        budget[0]+=1
        if depth>32 or budget[0]>8192:
            issues.append((at or '/')+':operand_traversal_limit');return
        if not isinstance(row,dict):
            issues.append((at or '/')+':operand_observation_not_object');return
        if id(row) in seen:
            issues.append((at or '/')+':operand_reference_cycle');return
        seen.add(id(row))
        missing=row.get('missing',[])
        if isinstance(missing,str):missing=[missing]
        if not isinstance(missing,list):missing=['operand_missing_shape_invalid']
        for message in missing:
            issues.append((at or '/')+':'+str(message))
        if row.get('available') is False and not missing:issues.append((at or '/')+':operand_reference_unavailable')
        if row.get('interpreted') is False and not missing:issues.append((at or '/')+':operand_not_interpreted')
        if 'values' in row:
            if not isinstance(row['values'],dict):issues.append((at or '/')+':operand_values_shape_invalid')
            else:
                for key,child in row['values'].items():
                    escaped=str(key).replace('~','~0').replace('/','~1')
                    visit(child,at+'/values/'+escaped,depth+1)
        if 'items' in row:
            if not isinstance(row['items'],list):issues.append((at or '/')+':operand_items_shape_invalid')
            else:
                for index,child in enumerate(row['items']):visit(child,at+'/items/'+str(index),depth+1)
        seen.remove(id(row))
    visit(value,path,0)
    return list(dict.fromkeys(issues))


def normalize_objective_operands(program, *, expected=None):
    """Return immutable-by-copy operands from a validated current Data program.

    Callers must separately verify freshness and current occurrence against
    their frame/receipt. No catalog text, static node operation, map marker,
    route, or quest number participates in this dispatch.
    """
    from .quest_program import load_schema
    schema=load_schema();data=program.get('data') or {};expected=expected or {}
    result={'status':'unsupported','ready':False,'operation':None,'data_type':data.get('type'),
        'scope':{k:deepcopy(program.get(k)) for k in SCOPE},'source':program.get('source'),
        'source_version':deepcopy(program.get('source_version')),'observed_at':program.get('observed_at'),
        'session_id':program.get('session_id'),'character_id':program.get('character_id'),
        'operands':{},'constraints':{},'missing':[],'requirements':[
            'fresh_current_same_accepted_occurrence','current_source_owned_receiver_and_input_guard',
            'current_native_effect_separate_from_inventory_or_input'],
        'raw_data':deepcopy(data),'normalization_only':True,'input_ready':False}
    gaps=result['missing']
    valid_scope=(program.get('confirmed') is True and program.get('owner_verified') is True
        and all(program.get(k) is not None for k in SCOPE)
        and type(program.get('instance_id')) is int and program['instance_id']>0
        and type(program.get('accept_time')) is int and program['accept_time']>0
        and bool(program.get('process_started_at'))
        and all(program.get(k) for k in ('source','session_id','character_id'))
        and all(program.get(k)==expected[k] for k in SCOPE if k in expected))
    try:valid_scope=valid_scope and datetime.fromisoformat(program['observed_at'].replace('Z','+00:00')).tzinfo is not None
    except (KeyError,TypeError,ValueError):valid_scope=False
    if not valid_scope:
        gaps.append('current_exact_owned_objective_scope');return result
    revision=program.get('source_version') or {}
    if not isinstance(revision,dict) or any(revision.get(k)!=schema[k] for k in ('game_assembly_sha256','metadata_sha256')):
        gaps.append('current_objective_source_revision_mismatch');return result
    specs={_full(s):s for s in schema['classes']}
    spec=specs.get(data.get('type'));disc=data.get('discriminant') or {}
    def source_type(row,definition,*,objective=False):
        if not definition or row.get('available') is not True or row.get('type')!=_full(definition):return False
        getter=next((g for g in definition.get('getters',[]) if g['name']=='get_Type' and 'constant' in g),None)
        if getter:
            observed=row.get('discriminant') or {}
            return (observed.get('verified') is True and observed.get('value')==getter['constant']
                and observed.get('declared_type')==_full(getter['return_type'])
                and observed.get('basis')=='pinned_get_Type_constant_bytes')
        return not objective
    if not source_type(data,spec,objective=True) or not spec['name'].endswith('QuestObjectiveData'):
        gaps.append('current_objective_type_discriminant_unverified');return result
    result['discriminant']=deepcopy(disc)
    name=spec['name'];values=data.get('values') or {};out=result['operands'];constraints=result['constraints']
    if not isinstance(values,dict):
        gaps.append('current_objective_values_shape_invalid');return result
    operations={
        'TalkQuestObjectiveData':'dialogue.talk','TalkGroupTagNPCQuestObjectiveData':'dialogue.talk',
        'ArriveAtRegionQuestObjectiveData':'objective.move','MoveToPermanentChannelQuestObjectiveData':'objective.move',
        'InteractInstantPropQuestObjectiveData':'objective.inspect','GainFakeItemByInteractInstantPropQuestObjectiveData':'objective.inspect',
        'ReadStoryBoxQuestObjectiveData':'objective.read_story','UseQuestFakeItemQuestObjectiveData':'objective.use_quest_item',
        'GatheringSuccessQuestObjectiveData':'life.gather','GatheringSuccessGroupTagGatherableQuestObjectiveData':'life.gather',
        'DeliverGroupTagConsumablesToNPCQuestObjectiveData':'objective.deliver',
        **dict.fromkeys(_SINGLE_DELIVERY,'objective.deliver'),**dict.fromkeys(_MULTIPLE_DELIVERY,'objective.deliver')}
    if name not in operations:
        gaps.extend(collect_operand_missing(data,'/data'))
        gaps.append('typed_operand_family_not_supported:'+name);return result
    result.update(status='partial',operation=operations[name])
    gaps.extend(collect_operand_missing(data,'/data'))

    def read(row,definition,key,path):
        fields={_label(f['name']):f['type'] for f in definition['fields'] if not f['type'].get('attrs',0)&16}
        declaration=fields.get(key);value=(row.get('values') or {}).get(key)
        if declaration is None or not isinstance(value,dict):
            gaps.append(path+':source_declared_operand_missing');return None
        if collect_operand_missing(value,path):return None
        return value,declaration

    def atom(row,definition,key,path,kind=None,positive=False):
        pair=read(row,definition,key,path)
        if pair is None:return None
        raw,t=pair;code=t['type_code'];valid=raw.get('interpreted') is True
        observed=raw.get('value');domain=None;declared=None
        if code==17:
            leaf=next((s for s in schema['classes'] if s['definition_index']==t.get('definition_index')),None)
            fields=[f for f in (leaf or {}).get('fields',[]) if not f['type'].get('attrs',0)&16]
            valid=valid and len(fields)==1 and fields[0]['offset']==16
            declared=_full(t)
            valid=valid and raw.get('declared_type')==declared and raw.get('member')==(fields[0]['name'] if fields else None)
            if t.get('namespace')=='Shared.Symbols' and fields and fields[0]['name']=='Id':
                domain=t['name'][:-2]
                valid=valid and raw.get('table_domain')==domain and raw.get('domain_mapping_verified') is True
            valid=valid and type(observed) is int
            if fields:valid=valid and raw.get('type_code')==fields[0]['type']['type_code']
        elif code==14:
            declared='System.String'
            valid=valid and raw.get('declared_type')==declared and isinstance(observed,str)
        else:
            declared={2:'System.Boolean',3:'System.Char',4:'System.SByte',5:'System.Byte',
                6:'System.Int16',7:'System.UInt16',8:'System.Int32',9:'System.UInt32',
                10:'System.Int64',11:'System.UInt64',12:'System.Single',13:'System.Double'}.get(code)
            valid=valid and raw.get('type_code')==code
            valid=valid and (type(observed) is int if code in (4,5,6,7,8,9,10,11) else
                type(observed) is bool if code==2 else type(observed) in (int,float) and math.isfinite(observed) if code in (12,13) else False)
        if positive and (type(observed) is not int or observed<=0):valid=False
        if not valid:
            gaps.append(path+':current_operand_declaration_or_value_unverified');return None
        normalized={'value':deepcopy(observed),'declared_type':declared,'type_code':raw.get('type_code'),
            'source_field':path,'raw':deepcopy(raw)}
        if domain:
            normalized.update(id=observed,table_domain=domain,kind=kind or _KINDS.get(domain,'typed_group'))
        elif kind:normalized['kind']=kind
        return normalized

    def field(key,kind=None,positive=False):
        return atom(data,spec,key,'/data/values/'+key,kind,positive)

    def put(key,value):
        if value is not None:out[key]=value

    def optional(key):
        path='/data/values/'+key;pair=read(data,spec,key,path)
        if pair is None:return
        raw,t=pair;args=t.get('arguments',[])
        valid=(_full(t)=='MM.QDBL.Definitions.ValueType.Optional`1' and len(args)==1
            and raw.get('optional') is True and type(raw.get('has_value')) is bool and raw.get('interpreted') is True)
        arg=(args or [{}])[0];actual=raw.get('optional_argument_type') or {}
        valid=valid and all(actual.get(k)==arg.get(k) for k in ('type_code','definition_index','name','namespace'))
        if valid and raw['has_value']:
            value=raw.get('value');code=arg['type_code']
            valid=(type(value) is bool if code==2 else isinstance(value,str) if code==14 else
                type(value) in (int,float) and math.isfinite(value) if code in (12,13) else type(value) is int)
        if not valid:gaps.append(path+':current_optional_declaration_or_presence_unverified');return
        constraints[key]={'has_value':raw['has_value'],'value':deepcopy(raw.get('value')) if raw['has_value'] else None,
            'source_field':path,'declared_argument_type':deepcopy(arg),'raw':deepcopy(raw),
            'default_semantics_applied':False}

    if 'NpcName' in values:put('npc',field('NpcName'))
    elif name.startswith(('TalkGroupTag','DeliverMultipleItemsToGroupTag')):
        put('npc_group_tag',field('NpcGroupTagName','npc_group_tag'))
        result['requirements'].append('current_source_group_tag_to_exact_NPC_resolution')
    if name in _SINGLE_DELIVERY:
        key,kind=_SINGLE_DELIVERY[name];item=field(key,kind);amount=field('Amount',positive=True)
        if item is not None:out['items']=[dict(item,amount=amount)]
    elif name in _MULTIPLE_DELIVERY:
        key=_MULTIPLE_DELIVERY[name];path='/data/values/'+key;pair=read(data,spec,key,path);out['items']=[]
        if pair is not None:
            raw,t=pair;elements=t.get('arguments',[]);rows=raw.get('items')
            if len(elements)!=1 or not isinstance(rows,list) or not rows or len(rows)>128:
                gaps.append(path+':current_declared_item_list_required')
            else:
                element=elements[0]
                for index,row in enumerate(rows):
                    at=path+'/items/'+str(index);child=specs.get(row.get('type')) if isinstance(row,dict) else None
                    child_name=(child or {}).get('name')
                    interface=element.get('name')=='IItemAmountPairData'
                    if (child_name not in _ITEM_ROWS or not source_type(row,child)
                        or (not interface and row.get('type')!=_full(element))
                        or (interface and not child_name.endswith('ItemAmountPairData'))):
                        gaps.append(at+':source_item_amount_type_unverified');continue
                    item_key,kind=_ITEM_ROWS[child_name]
                    item=atom(row,child,item_key,at+'/values/'+item_key,kind)
                    amount=atom(row,child,'Amount',at+'/values/Amount',positive=True)
                    if item is not None:out['items'].append(dict(item,amount=amount,source_index=index))
    elif name=='DeliverGroupTagConsumablesToNPCQuestObjectiveData':
        put('item_group_tag',field('ConsumableGroupTagName','consumable_group_tag'))
        put('amount',field('Amount',positive=True));result['requirements'].append('current_source_group_tag_to_exact_item_resolution')
    elif result['operation']=='objective.move':
        put('channel',field('ChannelName'));put('region',field('RegionName'))
        result['requirements'].append('current_source_channel_region_coordinate_and_arrival_binding')
    elif result['operation']=='objective.inspect':
        put('instant_prop',field('InstantPropName'))
        result['requirements'].extend(['current_owned_spawned_prop_instance_and_activation',
            'same_objective_auto_play_and_trait_operands_join','current_interaction_requirements_and_effect'])
        if name=='GainFakeItemByInteractInstantPropQuestObjectiveData':
            put('output_item',field('FakeItemSheetId'));put('goal_count',field('GoalCount',positive=True))
            result['requirements'].append('acquisition_event_effect_not_hold_inventory_quantity')
    elif result['operation']=='life.gather':
        key='GatherableName' if name=='GatheringSuccessQuestObjectiveData' else 'GatherableGroupTagName'
        put('gatherable' if key=='GatherableName' else 'gatherable_group_tag',field(key))
        put('goal_count',field('GoalCount',positive=True))
        result['requirements'].append('exact_typed_domain_route_join_and_current_source_life_selection')
        if key!='GatherableName':result['requirements'].append('current_source_group_tag_to_exact_gatherable_resolution')
    elif result['operation']=='objective.use_quest_item':
        put('item',field('FakeItemSheetId'));put('goal_count',field('GoalCount',positive=True))
        result['requirements'].extend(['current_owned_exact_quest_item_use_receiver',
            'current_unique_name_condition_and_native_use_effect_not_stock_delta'])
    elif result['operation']=='objective.read_story':
        key='StoryBoxList';path='/data/values/'+key;pair=read(data,spec,key,path)
        out['story_boxes']=[]
        if pair is not None:
            raw,t=pair;elements=t.get('arguments',[]);rows=raw.get('items')
            if (len(elements)!=1 or _full(elements[0])!='MM.QDBL.CodeGenerated.StoryBoxData'
                    or not isinstance(rows,list) or not rows or len(rows)>128):
                gaps.append(path+':current_declared_story_box_list_required')
            else:
                for index,row in enumerate(rows):
                    at=path+'/items/'+str(index);child=specs.get(row.get('type')) if isinstance(row,dict) else None
                    if not source_type(row,child) or row.get('type')!=_full(elements[0]):
                        gaps.append(at+':current_story_box_type_unverified');continue
                    out['story_boxes'].append(dict(source_index=index,source_field=at,raw=deepcopy(row)))
        result['requirements'].extend(['current_same_quest_story_box_receiver_and_action_binding',
            'native_read_effect_separate_from_opening_page_or_holding_item'])
    for f in spec['fields']:
        if f['type'].get('attrs',0)&16:continue
        key=_label(f['name'])
        if key not in values:gaps.append('/data/values/'+key+':source_declared_operand_missing')
        if _full(f['type'])=='MM.QDBL.Definitions.ValueType.Optional`1':optional(key)
    if result['operation']=='objective.deliver':
        result['requirements'].extend(['current_source_exact_item_domain_amount_and_delivery_mode',
            'current_owned_delivery_receiver_and_consumption_effect'])
    result['missing']=list(dict.fromkeys(gaps));result['ready']=not result['missing']
    if result['ready']:result['status']='supported'
    return result
