"""Bind HUD facts to an owned current quest; never infer same-step array order."""
from collections import Counter
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import re


def normalized(value):
    return ''.join(re.sub(r'<[^>]*>', '', str(value)).split())


OCCURRENCE_FIELDS=('quest_id','instance_id','accept_time','process_started_at')


def current_objective_occurrence(proof,snapshot,node,receipt=None):
    """An individual objective effect needs the current owned step/occurrence.

    A later step's native termination proof is a different contract; it cannot
    make an old objective counter into an individual success observation.
    """
    from .arbeit_operations import _fresh
    state=snapshot.get('quest_state_read') or {}
    if state and state.get('ok') is not True:return False
    current=state.get('current_quest') or snapshot.get('current_quest') or {}
    fields=OCCURRENCE_FIELDS+('step_id','step_index')
    if (current.get('confirmed') is not True or not _fresh(current,snapshot)
            or current.get('source')!='current-root-typed-quest-collections'
            or any(current.get(k) is None for k in fields)
            or type(current.get('accept_time')) is not int or current['accept_time']<=0
            or type(current.get('instance_id')) is not int or current['instance_id']<=0
            or not current.get('process_started_at') or proof.get('confirmed') is not True or not _fresh(proof,snapshot)
            or any(proof.get(k)!=current[k] for k in fields)
            or any(proof.get(k)!=node.get(k) for k in ('quest_id','step_id','objective_index'))):return False
    child=(receipt or {}).get('child_receipt') or {}
    scopes=[s for s in ((receipt or {}).get('goal_scope'),child.get('goal_scope'),child.get('requested_objective')) if s]
    if receipt is not None and not any(all(s.get(k) is not None for k in OCCURRENCE_FIELDS) for s in scopes):return False
    for scope in scopes:
        if (any(scope[k]!=current[k] for k in OCCURRENCE_FIELDS if k in scope)
                or any(scope[k]!=proof.get(k) for k in ('step_id','step_index','objective_index') if k in scope)):
            return False
    return True


def native_hold_request_verified(request):
    """Validate the binder's typed provenance without refreshing its timestamp."""
    from .quest_program import load_schema
    if not isinstance(request,dict):return False
    schema=load_schema();item=request.get('item') or {};effect=request.get('native_effect') or {}
    domain=item.get('table_domain');source=request.get('source_version') or {}
    if (request.get('ready') is not True or request.get('confirmed') is not True
            or request.get('semantics')!='inventory.hold'
            or request.get('basis')!='current_typed_hold_objective_and_verified_native_effective_goal'
            or request.get('quest_objective') is not True or request.get('requirement_origin')!='current_native_quest_hold'
            or domain not in ('ConsumableSheet','EquipmentSheet')
            or any(source.get(k)!=schema[k] for k in ('game_assembly_sha256','metadata_sha256'))
            or type(request.get('required_quantity')) is not int or request['required_quantity']<=0
            or effect.get('confirmed') is not True or type(effect.get('complete')) is not bool
            or effect.get('semantics')!='current_quest_objective_native_IsAchieved'
            or effect.get('basis')!='pinned_IsAchieved_GetGoal_IsStackableObjective_and_current_vtable_data_owner'
            or effect.get('goal')!=request['required_quantity']
            or any(request.get(k) is None or effect.get(k)!=request[k] for k in
                OCCURRENCE_FIELDS+('step_id','step_index','objective_index','session_id','character_id'))
            or request.get('source')!='current-root-typed-quest-collections' or effect.get('source')!=request['source']
            or request.get('effect_observed_at')!=effect.get('observed_at')
            or not request.get('observed_at') or not request.get('identity_binding')):
        return False
    expected=_hold_identity(domain,item.get('item_id'))
    return bool(expected and item==expected and request.get('item_name')==expected['name'])


@lru_cache(maxsize=1)
def _hold_item_definitions():
    """Pinned source identities only. These are not observed bag instances."""
    from .paths import root
    catalog_path=root()/'data/arbeit-catalog.json'
    catalog=json.loads(catalog_path.read_text(encoding='utf8'))
    records=[]
    for quest in catalog['quests']:
        for step in quest['steps']:
            for objective in step.get('objectives',[]):
                for target in objective.get('targets',[]):
                    table=target.get('table_data') or {}
                    identity=target.get('identity_resolution') or {}
                    source=identity.get('table_source_ref') or {}
                    if (target.get('kind')=='item' and type(table.get('raw_id')) is int
                            and isinstance(table.get('display_name'),str)
                            and source.get('sheet')=='Consumable' and source.get('sha256')):
                        records.append({'item_id':table['raw_id'],'name':table['display_name'],
                            'symbol':(target.get('arguments') or [None])[0],
                            'table_domain':'ConsumableSheet','kind':'consumable','source_ref':deepcopy(source)})
    equipment_path=root()/'data/arbeit-craft/equipment.json'
    if equipment_path.exists():
        raw=equipment_path.read_bytes();equipment=json.loads(raw)
        for row in equipment.get('items',[]):
            if (type(row.get('item_id')) is int and isinstance(row.get('name'),str)
                    and isinstance(row.get('symbol'),str) and (row.get('source_ref') or {}).get('sheet')=='Equipment'):
                records.append(dict(deepcopy(row),table_domain='EquipmentSheet',kind='equipment',
                    definition_artifact='data/arbeit-craft/equipment.json',
                    definition_artifact_sha256=hashlib.sha256(raw).hexdigest()))
    return records


def _hold_identity(domain, raw_id):
    rows=[r for r in _hold_item_definitions() if r['table_domain']==domain and r['item_id']==raw_id]
    # An equal numeric ID from another table is not the same item.
    if not rows or len({(r['name'],r.get('symbol')) for r in rows})!=1:return None
    result=deepcopy(rows[0])
    result.update(declared_type='Shared.Symbols.'+domain+'Id',
        definition_scope='pinned_client_source',observed_inventory_instance=False)
    return result


def _native_hold_requests(nodes, state, current_scope, effects, conflicting):
    """Preserve native completion and separately expose exact acquisition goals."""
    from .quest_program import load_schema
    schema=load_schema();schemas={s['name']:s for s in schema['classes']}
    kinds={'HoldConsumableQuestObjectiveData':('ConsumableName','ConsumableSheet'),
           'HoldEquipmentQuestObjectiveData':('EquipmentName','EquipmentSheet')}
    requests=[];missing=[]
    for node in nodes:
        if node['id'] in conflicting:continue
        effect=effects.get(node['id'])
        if (not effect or effect.get('semantics')!='current_quest_objective_native_IsAchieved'
                or effect.get('confirmed') is not True or type(effect.get('goal')) is not int or effect['goal']<=0):continue
        rows=[p for p in state.get('objective_programs',[]) if current_scope(p)
              and p.get('confirmed') is True and p.get('owner_verified') is True
              and all(p.get(k)==node.get(k) for k in ('quest_id','step_id','objective_index'))]
        if len(rows)!=1:continue
        program=rows[0];data=program.get('data') or {};full=data.get('type','')
        name=full.removeprefix('MM.QDBL.CodeGenerated.')
        if full!='MM.QDBL.CodeGenerated.'+name or name not in kinds:continue
        field,domain=kinds[name];spec=schemas[name]
        constant=next(g['constant'] for g in spec['getters'] if g['name']=='get_Type')
        disc=data.get('discriminant') or {}
        if (data.get('available') is not True or disc.get('verified') is not True
                or disc.get('value')!=constant
                or any((program.get('source_version') or {}).get(k)!=schema[k] for k in ('game_assembly_sha256','metadata_sha256'))):
            missing.append('native_hold_source_type_or_revision_unverified:'+node['id']);continue
        value=(data.get('values') or {}).get(field) or {}
        if (value.get('interpreted') is not True or value.get('domain_mapping_verified') is not True
                or value.get('table_domain')!=domain or value.get('declared_type')!='Shared.Symbols.'+domain+'Id'
                or type(value.get('value')) is not int or value['value']==0
                or (domain=='EquipmentSheet' and value.get('has_value') is not True)):
            missing.append('native_hold_exact_item_id_required:'+node['id']);continue
        constraints={};valid=True
        if domain=='EquipmentSheet':
            for key in ('Rarity','Season'):
                condition=(data.get('values') or {}).get(key) or {}
                declaration=next(f for f in spec['fields'] if f['name']=='<'+key+'>k__BackingField')['type']['arguments'][0]
                observed=condition.get('optional_argument_type') or {}
                if (condition.get('interpreted') is not True or type(condition.get('has_value')) is not bool
                        or any(observed.get(k)!=declaration.get(k) for k in ('type_code','definition_index','name','namespace'))):
                    valid=False;missing.append('native_hold_equipment_constraint_unverified:'+key+':'+node['id'])
                constraints[key]=deepcopy(condition)
        if not valid:continue
        item=_hold_identity(domain,value['value'])
        if item is None:
            missing.append('native_hold_item_definition_unresolved:'+domain+':'+str(value['value']));continue
        owner={k:deepcopy(program.get(k)) for k in ('quest_id','instance_id','accept_time','step_id','step_index','objective_index',
            'source','observed_at','session_id','character_id','process_started_at','source_version')}
        request=dict(owner,node_id=node['id'],confirmed=True,ready=True,semantics='inventory.hold',
            quest_objective=True,requirement_origin='current_native_quest_hold',occurrence_verified=True,
            required_quantity=effect['goal'],observed_objective_count=effect['count'],item=item,
            item_name=item['name'],identity_binding='current_typed_'+field+'_domain_and_ID_to_pinned_source_row',
            basis='current_typed_hold_objective_and_verified_native_effective_goal',
            constraints=constraints,supply_requires_constraint_match=any(v.get('has_value') is True for v in constraints.values()),
            inventory_count_completion_allowed=domain=='ConsumableSheet',
            native_effect=deepcopy(effect),effect_observed_at=effect['observed_at'],
            inventory_scope_required='current_bag_'+domain,
            quantity_source='native_effective_goal; not HUD text or static GoalCount')
        # Keep the join as old as its oldest source. It must not refresh an
        # effect from an earlier observation by borrowing the new request time.
        request['observed_at']=min(datetime.fromisoformat(p['observed_at'].replace('Z','+00:00')) for p in (program,effect)).isoformat()
        requests.append(request)
    return requests,missing


def bind_objectives(graph, envelope, *, character_id, session_id, observation_id,
                    inventory=None, quest_state=None, now=None):
    from .arbeit_runtime import normalize_quest_read
    from .arbeit_graph import load_catalog
    from .arbeit_operations import _fresh
    context = dict(character_id=character_id, session_id=session_id,
                   now=now or datetime.now(timezone.utc).isoformat())
    state = quest_state or {}
    current = state.get('current_quest') or {}
    owned = (state.get('ok') is True and current.get('quest_id') == graph['quest_id']
             and current.get('confirmed') is True and _fresh(current, context)
              and type(current.get('instance_id')) is int and current['instance_id']>0
             and type(current.get('accept_time')) is int and current['accept_time'] > 0
              and current.get('step_id') is not None and current.get('step_index') is not None
              and bool(current.get('process_started_at'))
              and current.get('source')=='current-root-typed-quest-collections')
    nodes = [n for n in graph['nodes'] if n.get('objective_index') is not None
             and (not owned or n.get('step_id') == current['step_id'])]
    result, missing, conflicting, supporting = {}, [], set(), []
    def current_scope(proof):
        return (isinstance(proof,dict) and _fresh(proof,context)
                and all(proof.get(k)==current.get(k) for k in
                    ('quest_id','instance_id','accept_time','step_id','process_started_at'))
                and proof.get('step_index')==current.get('step_index'))
    # Native effective-goal/completion decoding, when verified by the supplier,
    # is indexed evidence. Raw count/goal bytes never become completion proof.
    if owned:
        for proof in state.get('objective_evidence', []):
            matches = [n for n in nodes if all(proof.get(k) == n.get(k)
                for k in ('quest_id', 'step_id', 'objective_index'))]
            if (len(matches) == 1 and current_scope(proof)
                    and proof.get('confirmed') is True and type(proof.get('complete')) is bool
                    and type(proof.get('count')) is int and type(proof.get('goal')) is int
                    and proof.get('semantics')=='current_quest_objective_native_IsAchieved'
                    and proof.get('basis')=='pinned_IsAchieved_GetGoal_IsStackableObjective_and_current_vtable_data_owner'):
                node_id=matches[0]['id']
                if node_id in conflicting:continue
                prior=result.get(node_id)
                if prior and any(prior.get(k)!=proof.get(k) for k in ('count','goal','complete')):
                    result.pop(node_id);conflicting.add(node_id)
                    missing.append('conflicting_current_indexed_objective:'+node_id)
                    continue
                result[node_id] = dict(deepcopy(proof), node_id=node_id,occurrence_verified=True)
    parsed = normalize_quest_read(envelope)
    hud = [q for q in parsed['quests'] if normalized(q['QuestTitle']) == normalized(graph['title'])]
    hud_stamp={'source':envelope.get('source') or 'get_quests','observed_at':envelope.get('observed_at'),
               'character_id':character_id,'session_id':session_id}
    if not _fresh(hud_stamp,context):
        missing.append('fresh_current_HUD_quest_required');hud=[]
    if current and not owned:
        # A stale/different supplied owner must not be replaced by a title-only
        # guess. The caller can still use the separate raw HUD for display.
        missing.append('current_quest_owner_scope_unverified');hud=[]
    elif not owned:missing.append('current_quest_owner_scope_unverified')
    if len(hud) != 1 or parsed['uncertainty']:
        missing.append('current_exact_HUD_quest_unique_match')
        hud = []
    if hud and not owned:
        descriptions = {normalized(o['Description']) for o in hud[0]['Objectives']}
        variants = [q['id'] for q in load_catalog()['quests'] if normalized(q['title']) == normalized(hud[0]['QuestTitle'])
            and descriptions and descriptions.issubset({normalized(o['text']) for s in q['steps'] for o in s['objectives']})]
        if variants != [graph['quest_id']]:
            missing.append('quest_variant_binding_ambiguous')
            hud = []
    counts = Counter(normalized(r['Description']) for row in hud for r in row['Objectives'])
    for row in hud:
        for observed in row['Objectives']:
            text = normalized(observed['Description'])
            candidates = [n for n in nodes if normalized(n['params']['objective']['text']) == text]
            if len(candidates) != 1 or counts[text] != 1:
                # Native indexed evidence can distinguish repeated labels;
                # official HUD rows expose no objective ID or stable array key.
                if any(n['id'] not in result for n in candidates) or not candidates:
                    missing.append('current_indexed_objective_required:' + observed['Description'])
                continue
            node = candidates[0]
            if node['id'] in result or node['id'] in conflicting:
                continue
            proof = dict(quest_id=graph['quest_id'], step_id=node['step_id'], objective_index=node['objective_index'],
                node_id=node['id'], complete=observed['IsCompleted'], count=observed['Count'], goal=observed['Goal'],
                observed_at=envelope['observed_at'], source='get_quests', observation_id=observation_id,
                character_id=character_id, session_id=session_id, raw_current=deepcopy(observed),
                identity_binding='current owned quest+step and unique HUD description' if owned else 'unique current HUD title+objective to static definition',
                scope='current_HUD_quests_only')
            if owned:
                proof.update(instance_id=current['instance_id'], accept_time=current['accept_time'],
                    step_index=current.get('step_index'),process_started_at=current.get('process_started_at'),
                    confirmed=True,occurrence_verified=True)
            items = [t for t in node['params'].get('targets', []) if t.get('kind') == 'item' and t.get('table_data')]
            if (node['op'] == 'inventory.hold' and len(items) == 1 and '보유' in observed['Description']
                    and normalized(items[0]['table_data']['display_name']) in text and observed['Goal'] >= 0):
                proof.update(semantics='inventory.hold', item_name=items[0]['table_data']['display_name'], required_quantity=observed['Goal'])
                categories = {r['category'] for r in (inventory or {}).get('items', [])
                    if r['name'] == proof['item_name'] and r['scope'] == 'bag'}
                if len(categories) == 1:
                    proof['item_category'] = next(iter(categories))
            if owned:result[node['id']] = proof
            else:supporting.append(dict(proof,confirmed=False,occurrence_verified=False,effect_eligible=False,
                scope='display_only_unowned_HUD; cannot resolve persisted objective or input'))
    hold_requests,hold_missing=_native_hold_requests(nodes,state,current_scope,result,conflicting) if owned else ([],[])
    missing.extend(hold_missing)
    evidence = list(result.values())
    active = [{k:p[k] for k in ('quest_id','step_id','objective_index')}
              for p in evidence if p.get('complete') is False]
    # Preserve current indexed identity even if an effect has not been decoded.
    if owned:
        for d in state.get('current_descriptors', [])+state.get('objective_programs', []):
            if (current_scope(d) and d.get('confirmed') is True
                    and any(n['id'] not in conflicting and all(d.get(k)==n.get(k) for k in ('quest_id','step_id','objective_index')) for n in nodes)):
                key = {k:d[k] for k in ('quest_id','step_id','objective_index')}
                if key not in active and not any(p.get('complete') is True and all(p.get(k)==v for k,v in key.items()) for p in evidence):
                    active.append(key)
    return dict(objective_evidence=evidence, active_objectives=active, missing=missing,
                inventory_hold_requests=hold_requests,
                supporting_objective_observations=supporting,current_hud_quest=deepcopy(hud[0]) if hud else None,
                current_quest=deepcopy(current) if owned else None)
