"""Current owned dialogue root, prompt and selection observations.

Import is inert. Optional read transport only; this module never captures or
inputs. Raw DialogueId and QuestInstanceId remain separate identities.
"""
from __future__ import annotations
from datetime import datetime,timezone
from functools import lru_cache
import hashlib,json,struct
from pathlib import Path
from .paths import root
from .ui_layout_observer import CurrentUILayoutSession
from .vendor.map_memory import MapReadPending
from .vendor.map_character_context import active_context
from .vendor.map_character_identity import CharacterMemoryReader


@lru_cache(maxsize=1)
def dialogue_rules():
    return json.loads((root()/'data/arbeit-dialogue-binding.json').read_text(encoding='utf-8'))


class DialogueBindingSession(CurrentUILayoutSession):
    def verify_name_source(self):
        """Compare the bounded current asset range; never reinterpret intern IDs."""
        rule=dialogue_rules()['static_name_binding'];location=rule['source_range']
        path=Path(self.reader.image).parent/location['blob']
        try:
            with path.open('rb') as stream:
                stream.seek(location['offset']);raw=stream.read(location['length'])
        except OSError as exc:
            return {'verified':False,'kind':rule['kind'],'source':str(path),'missing':str(exc)}
        verified=len(raw)==location['length'] and hashlib.sha256(raw).hexdigest()==rule['raw_sha256']
        return {'verified':verified,'kind':rule['kind'],'raw_sha256':hashlib.sha256(raw).hexdigest(),
                'source':str(path),'length':len(raw),'offset':location['offset']}

    def source_shape(self,name,namespace=None):
        rules=dialogue_rules()
        if any(rules[k]!=self.plan[k] for k in ('metadata_sha256','game_assembly_sha256')):
            raise ValueError('Dialogue source and transport revisions differ')
        choices=[c for c in rules['classes'] if c['name']==name and (namespace is None or c['namespace']==namespace)]
        if len(choices)==1:return choices[0]
        if choices:raise ValueError('Ambiguous dialogue type '+name)
        from .direct_dialogue_source import direct_source
        source=direct_source()
        if any(source[k]!=self.plan[k] for k in ('metadata_sha256','game_assembly_sha256')):
            raise ValueError('Direct dialogue source and transport revisions differ')
        choices=[c for c in source['classes'] if c['name']==name and (namespace is None or c['namespace']==namespace)]
        if len(choices)==1:return choices[0]
        if choices:raise ValueError('Ambiguous direct dialogue type '+name)
        return super().source_shape(name,namespace)

    def field(self,pointer,owner,name):
        shape=self.source_shape(owner);self.fields(self.reader.u64(pointer),shape)
        return next(f for f in shape['fields'] if f['name']==name)

    def reference(self,pointer,owner,name):
        field=self.field(pointer,owner,name)
        if field['type']['type_code'] not in (18,21,28,29):raise ValueError('Unexpected dialogue reference '+name)
        return struct.unpack('<Q',self.watch(pointer+field['offset'],8))[0]

    def primitive(self,pointer,owner,name):
        field=self.field(pointer,owner,name);code=field['type']['type_code'];at=pointer+field['offset']
        if code==14:
            p=struct.unpack('<Q',self.watch(at,8))[0]
            return self.reader.string(p) if p else None
        if code==2:
            self.watch(at,1);return self.bool(at)
        fmt={5:'<B',8:'<i',9:'<I',10:'<q',11:'<Q'}.get(code)
        if not fmt:raise ValueError('Unexpected primitive dialogue field '+name)
        return struct.unpack(fmt,self.watch(at,struct.calcsize(fmt)))[0]

    def state_present(self,pointer,owner,name,state_type):
        field=self.field(pointer,owner,name);state=self.source_shape(state_type)
        part=next(f for f in state['fields'] if f['name']=='hasValue')
        if (field['type'].get('definition_index')!=state['type_index'] or part['offset']!=16
                or part['type']['type_code']!=2):raise ValueError('Replicated dialogue presence type changed')
        address=pointer+field['offset'];self.watch(address,1)
        return self.bool(address)

    def owned_roots(self,context):
        """Same character root as quest observer; native getters are not called."""
        singleton,entity=context['chain'][1],context['chain'][3]
        player=self.reference(entity,'SingularEntity','IDialoguePlayerComponent')
        replicated_system=self.reference(singleton,'IntegrationSingleton','<Replicated>k__BackingField')
        singular=self.reference(replicated_system,'ReplicatedSystem','<SingularReplicatedEntity>k__BackingField')
        present=self.state_present(singular,'SingularReplicatedEntity','DialoguePlayerReplicatedUpdateState','ReplicatedUpdateState')
        replicated=self.reference(singular,'SingularReplicatedEntity','DialoguePlayerReplicated') if present else 0
        return {'player':player,'replicated':replicated,'replicated_present':present}

    def prompt_selections(self,pointer):
        """Closed MMReadOnlyList<DialoguePromptSelectionData>, a value array."""
        if not pointer:return []
        r=self.reader;klass=r.u64(pointer);fields=None
        for _ in range(3):
            name,namespace=r.class_name(klass)
            if name not in ('MMReadOnlyList`1','MMList`1') or namespace!='Silvervine.ManualMemory':
                raise ValueError('Dialogue prompt collection type changed')
            shape=r.layout(klass,[f['name'] for f in self.source_shape(name)['fields']])
            if name=='MMReadOnlyList`1':fields={f['name']:f for f in shape['fields']};break
            klass=r.u64(klass+0x58)
        if fields is None or set(fields)!={'array','version','size'}:raise ValueError('Dialogue prompt list base unavailable')
        if (fields['array']['type_code'],fields['version']['type_code'],fields['size']['type_code'])!=(29,8,8):
            raise ValueError('Dialogue prompt list fields changed')
        array=struct.unpack('<Q',self.watch(pointer+fields['array']['offset'],8))[0]
        count=struct.unpack('<i',self.watch(pointer+fields['size']['offset'],4))[0]
        self.watch(pointer+fields['version']['offset'],4)
        if not 0<=count<=128:raise ValueError('Dialogue prompt list exceeds bound')
        source=self.source_shape('DialoguePromptSelectionData');stride=source['type_definition_size_raw'][0]-16
        capacity,_=self.array(array,name='DialoguePromptSelectionData[]',stride=stride,limit=4096)
        if capacity<count:raise ValueError('Dialogue prompt list truncated')
        element_class=r.u64(r.u64(array)+0x40);self.fields(element_class,source)
        self.watch(array+24,8)
        offsets={f['name']:f['offset']-16 for f in source['fields'] if not f['type'].get('attrs',0)&16}
        rows=[]
        for index in range(count):
            at=array+32+index*stride;raw=self.watch(at,stride)
            def integer(name,fmt):return struct.unpack_from(fmt,raw,offsets[name])[0]
            text=integer('TargetKeywordName','<Q');enabled=integer('IsEnable','<B')
            if enabled not in (0,1):raise ValueError('Invalid current selection boolean')
            kind=integer('Type','<i');enums=self.source_shape('DialoguePromptSelectionType')['enum_constants']
            labels=[n for n,v in enums.items() if v==kind]
            if len(labels)!=1:raise ValueError('Unknown current selection type')
            rows.append({'selection_index':index,'type':{'value':kind,'name':labels[0]},
                'keyword_name':r.string(text) if text else None,'dialogue_id':integer('DialogueId','<q'),'enabled':bool(enabled)})
        return rows

    def play_data(self,pointer):
        if not pointer:return None
        owner='DialoguePlayData'
        result={'dialogue_id':self.inline_value(pointer,owner,'<Id>k__BackingField','DialogueId','id'),
                'npc_id':self.inline_value(pointer,owner,'<NPCSheetId>k__BackingField','NPCSheetId','Id'),
                'conditions':[]}
        action=self.reference(pointer,owner,'<DialogueAction>k__BackingField')
        if action:
            name,namespace=self.reader.class_name(self.reader.u64(action))
            result['action']={'type':name,'namespace':namespace}
            if name in ('AcceptQuestDialogueActionData','AchieveQuestObjectiveDialogueActionData'):
                self.source_fields(action,name)
                result['action']['direct_quest_identity']=None
                from .direct_dialogue_source import action_branches
                result['action']['branches']=action_branches(self,action,name)
                # This is an additional request-context observation. Keep it out
                # of the r5 UI-effect fingerprint and legacy action equality:
                # adding a reader is not a game-side dialogue transition.
                from .direct_dialogue_operands import read_direct_dialogue_operands
                try:
                    result['direct_action_structure']=read_direct_dialogue_operands(self,action)
                except ValueError as exc:
                    result['direct_action_structure_error']=str(exc)
        else:result['action']=None
        conditions=self.reference(pointer,owner,'<DialogueConditionList>k__BackingField')
        allowed={'QuestAcceptableDialogueConditionData','QuestObjectiveInProgressDialogueConditionData',
                 'QuestObjectiveWaitingForAchieveDialogueConditionData','ArbeitReceivingDialogueConditionData'}
        for entry in self.refs(conditions,element='IDialogueConditionData',limit=128):
            name,namespace=self.reader.class_name(self.reader.u64(entry))
            row={'type':name,'namespace':namespace,'action_owner':False}
            if name in allowed:
                self.source_fields(entry,name)
                row['quest_name']=self.primitive(entry,name,'<QuestName>k__BackingField')
                if name in ('QuestObjectiveInProgressDialogueConditionData','QuestObjectiveWaitingForAchieveDialogueConditionData'):
                    row['step_id']=self.primitive(entry,name,'<StepId>k__BackingField')
                    row['objective_index']=self.primitive(entry,name,'<ObjectiveIndex>k__BackingField')
            result['conditions'].append(row)
        return result

    def current_dialogue(self):
        self._guards=[];r=self.reader;context=active_context(r,self.base,self.character_plan)
        identity=CharacterMemoryReader._value(r,context['identity_pointer'],self.identity_class,self.identity_shape)
        roots=self.owned_roots(context);player=roots['player'];replicated=roots['replicated']
        result={'source':'current-owned-dialogue-roots','observed_at':datetime.now(timezone.utc).isoformat(),
                'pid':r.pid,'process_started_at':r.started_at.isoformat(),'identity':identity,
                'source_version':dialogue_rules()['game_assembly_sha256'],'present':bool(player and replicated),
                'static_name_binding':self.verify_name_source(),
                'player':None,'replicated':None,'game_inputs':0,'memory_writes':0}
        if player:
            owner='DialoguePlayerComponent';self.source_fields(player,owner)
            result['player']={k:self.primitive(player,owner,f) for k,f in {
                'dialogue_index':'<DialogueIndex>k__BackingField','is_active':'<IsActive>k__BackingField',
                'is_reloading':'<IsReloading>k__BackingField','triggered_by_quest_acceptable':'<IsTriggeredByQuestAcceptable>k__BackingField'}.items()}
            result['player']['current_npc_id']=self.inline_value(player,owner,'currentNPCSheetId','NPCSheetId','Id')
            result['player']['target_object_id']=self.inline_value(player,owner,'<TargetObjectId>k__BackingField','ChannelObjectId','int64Value')
            from .world_object_observer import read_nullable_entity_key
            result['player']['target_entity_key']=read_nullable_entity_key(self,player,self.source_shape(owner),'targetEntityKey')
            result['player']['play_data']=self.play_data(self.reference(player,owner,'<DialoguePlayData>k__BackingField'))
        if replicated:
            owner='DialoguePlayerReplicated';self.source_fields(replicated,owner)
            result['replicated']={k:self.primitive(replicated,owner,f) for k,f in {
                'is_playing':'<IsPlaying>k__BackingField','dialogue_index':'<DialogueIndex>k__BackingField',
                'prompt_index':'<PromptIndex>k__BackingField',
                'triggered_by_quest_acceptable':'<IsTriggeredByQuestAcceptable>k__BackingField'}.items()}
            result['replicated']['prompt_type']=self.enum_value(replicated,owner,'<PromptType>k__BackingField','DialoguePromptType')
            result['replicated']['target_object_id']=self.inline_value(replicated,owner,'<TargetObjectId>k__BackingField','ChannelObjectId','int64Value')
            result['replicated']['play_data']=self.play_data(self.reference(replicated,owner,'<DialoguePlayData>k__BackingField'))
            result['replicated']['selections']=self.prompt_selections(self.reference(replicated,owner,'<PromptSelections>k__BackingField'))
        # This component prevents duplicate current selections. It is rooted in
        # SingularEntity, not a guessed field on DialoguePlayerComponent.
        if player and replicated:
            from .direct_dialogue_request import observe_direct_request_component
            try:result['request_component']=observe_direct_request_component(self,context)
            except ValueError as exc:result['request_component_error']=str(exc)
        if active_context(r,self.base,self.character_plan)!=context or self.owned_roots(context)!=roots:
            raise MapReadPending('Dialogue character roots changed during read')
        if CharacterMemoryReader._value(r,context['identity_pointer'],self.identity_class,self.identity_shape)!=identity:
            raise MapReadPending('Dialogue character identity changed during read')
        if any(r.read(at,len(raw))!=raw for at,raw in self._guards):raise MapReadPending('Dialogue prompt changed during read')
        result['stable']=True
        return result


def dialogue_program_structure(play_data):
    """The typed program fields this reader actually observes.

    Include branch presence and quest constraint operands, never condition
    evaluation, inventory counts, observation timestamps or pointer addresses.
    These are context and UI revision fields; they do not identify the quest
    whose effect an Accept/Report action will execute. Unknown condition
    payloads are not claimed to be observed by this projection.
    """
    action=play_data.get('action')
    return {
        'action':({k:action[k] for k in ('type','namespace','branches') if k in action}
                  if isinstance(action,dict) else None),
        'conditions':[{k:c[k] for k in ('type','namespace','quest_name','step_id','objective_index') if k in c}
                      for c in play_data.get('conditions',[])],
    }


def dialogue_fingerprint(snapshot, *, include_program=True):
    """Current UI revision; legacy pending receipts retain their original hash."""
    p=snapshot.get('player') or {};r=snapshot.get('replicated') or {};d=r.get('play_data') or {}
    value={'present':snapshot.get('present'),'is_active':p.get('is_active'),'is_playing':r.get('is_playing'),
        'dialogue_index':r.get('dialogue_index'),'prompt_index':r.get('prompt_index'),'dialogue_id':d.get('dialogue_id'),
        'npc_id':d.get('npc_id'),'selections':r.get('selections',[]),
        'target_object_id':p.get('target_object_id'),'target_entity_key':p.get('target_entity_key')}
    if include_program:value['program_structure']=dialogue_program_structure(d)
    return hashlib.sha256(json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode()).hexdigest()


def validate_current_dialogue(snapshot,frame):
    """Return a concrete gap list without converting missing state to false."""
    from .arbeit_operations import _fresh
    gaps=[]
    if snapshot.get('source')!='current-owned-dialogue-roots' or snapshot.get('stable') is not True:
        gaps.append('stable_current_owned_dialogue_required')
    if str(snapshot.get('identity',{}).get('character_id'))!=str(frame.get('character_id')):
        gaps.append('dialogue_character_mismatch')
    if not frame.get('process_started_at') or snapshot.get('process_started_at')!=frame['process_started_at']:
        gaps.append('dialogue_process_mismatch')
    evidence={**snapshot,'session_id':frame.get('session_id'),'character_id':frame.get('character_id')}
    if not _fresh(evidence,{**frame,'now':frame.get('captured_at')}):gaps.append('dialogue_read_not_current')
    p=snapshot.get('player');r=snapshot.get('replicated')
    if not snapshot.get('present') or not p or not r:return gaps+['current_dialogue_not_present']
    if p.get('is_active') is not True or p.get('is_reloading') is not False or r.get('is_playing') is not True:
        gaps.append('current_dialogue_not_settled')
    if p.get('dialogue_index')!=r.get('dialogue_index'):gaps.append('player_replicated_dialogue_index_mismatch')
    pd=p.get('play_data') or {};rd=r.get('play_data') or {}
    gaps.extend(condition_support_gaps(pd))
    gaps.extend(g for g in condition_support_gaps(rd) if g not in gaps)
    if not pd or any(pd.get(k)!=rd.get(k) for k in ('dialogue_id','npc_id','action','conditions')):
        gaps.append('player_replicated_play_data_mismatch')
    if p.get('current_npc_id')!=rd.get('npc_id'):gaps.append('current_dialogue_npc_mismatch')
    if ('target_object_id' in p or 'target_object_id' in r) and p.get('target_object_id')!=r.get('target_object_id'):
        gaps.append('player_replicated_target_object_mismatch')
    return gaps


def condition_support_gaps(play):
    """Unknown conditions are unsupported, never implicitly true or false."""
    known={'QuestAcceptableDialogueConditionData','QuestObjectiveInProgressDialogueConditionData',
           'QuestObjectiveWaitingForAchieveDialogueConditionData','ArbeitReceivingDialogueConditionData'}
    conditions=play.get('conditions')
    if not isinstance(conditions,list):return ['dialogue_conditions_current_list_missing']
    gaps=[]
    for row in conditions:
        kind=row.get('type') if isinstance(row,dict) else None
        if kind not in known:
            gaps.append('dialogue_condition_unsupported:'+str(kind));continue
        required=('quest_name','step_id','objective_index') if kind in (
            'QuestObjectiveInProgressDialogueConditionData','QuestObjectiveWaitingForAchieveDialogueConditionData') else ('quest_name',)
        for field in required:
            if row.get(field) is None:gaps.append('dialogue_condition_payload_missing:'+kind+':'+field)
    return gaps


def bind_current_selection(snapshot,current_ui,descriptor):
    """Bind actual UI Name/index to the same ordered replicated prompt.

    This establishes the server selection being invoked, not a quest owner.
    """
    gaps=condition_support_gaps((snapshot.get('replicated') or {}).get('play_data') or {})
    if gaps:return None,gaps
    context=current_ui.get('layout_snapshot',{}).get('details',{}).get('dialogue_context',{})
    replicated=snapshot.get('replicated') or {};rows=context.get('keyword_rows',[])
    selections=replicated.get('selections',[])
    if context.get('prompt_type')!=replicated.get('prompt_type'):
        return None,['ui_replicated_prompt_type_mismatch']
    if len(rows)!=len(selections):return None,['ui_replicated_prompt_count_mismatch']
    for row,selection in zip(rows,selections):
        if any(row.get(k)!=selection.get(k) for k in ('selection_index','type','enabled')):
            return None,['ui_replicated_prompt_order_mismatch']
    key=descriptor.get('keywordId')
    candidates=[r for r in rows if key and r.get('keyword_id')==key and r.get('text')==descriptor.get('displayName')]
    if len(candidates)!=1:return None,['current_keyword_name_descriptor_not_unique']
    index=candidates[0]['selection_index']
    if type(index) is not int or not 0<=index<len(selections):return None,['current_keyword_selection_index_invalid']
    selected=selections[index]
    if selected.get('enabled') is not True or descriptor.get('isEnable') is not True:
        return None,['current_keyword_disabled']
    if descriptor.get('onClickEvent',{}).get('event_present') is not True:return None,['current_keyword_callback_missing']
    return {**selected,'keyword_id':key,'prompt_index':replicated.get('prompt_index'),
            'dialogue_index':replicated.get('dialogue_index'),'selection_identity_verified':True},[]


def talk_objective_owner(snapshot,frame,node):
    """TalkQuestObjectiveData explicitly requires NpcName, not a dialogue ID.

    Only current typed objective programs supply this proof. Static graph NPCs
    and a matching displayed title are insufficient.
    """
    from .arbeit_operations import _fresh
    matches=[]
    for program in frame.get('objective_programs',[]):
        if not _fresh(program,{**frame,'now':frame.get('captured_at')}):continue
        if program.get('confirmed') is not True or program.get('owner_verified') is not True:continue
        if any(program.get(k)!=node.get(k) for k in ('quest_id','step_id','objective_index')):continue
        if program.get('process_started_at')!=frame.get('process_started_at'):continue
        data=program.get('data',{});disc=data.get('discriminant',{});npc=data.get('values',{}).get('NpcName',{})
        if (data.get('type')!='MM.QDBL.CodeGenerated.TalkQuestObjectiveData' or data.get('available') is not True
                or disc.get('verified') is not True or disc.get('value')!=14
                or npc.get('declared_type')!='Shared.Symbols.NPCSheetId' or npc.get('member')!='Id'
                or npc.get('domain_mapping_verified') is not True or npc.get('interpreted') is not True):continue
        matches.append((program,npc.get('value')))
    if len(matches)!=1:return None,['current_typed_talk_objective_owner_not_unique']
    program,npc_id=matches[0];player=snapshot.get('player') or {};play=(snapshot.get('replicated') or {}).get('play_data') or {}
    if type(npc_id) is not int or any(type(v) is not int or (v&0xffffffff)!=(npc_id&0xffffffff)
        for v in (player.get('current_npc_id'),play.get('npc_id'))):return None,['talk_objective_current_npc_mismatch']
    owner={k:program[k] for k in ('quest_id','instance_id','accept_time','step_id','objective_index') if k in program}
    if any(owner.get(k) is None for k in ('instance_id','accept_time')):return None,['talk_accepted_occurrence_missing']
    owner.update(node_id=node['id'],npc_id=npc_id,npc=str(npc_id),ownership_verified=True,
        source='current typed TalkQuestObjectiveData.NpcName joined to owned current dialogue',
        source_revision=program.get('source_version'),observed_at=snapshot['observed_at'])
    return owner,[]


def expected_dialogue_progress(snapshot,owner):
    return {**{k:owner.get(k) for k in ('node_id','quest_id','instance_id','accept_time','step_id','objective_index','source_revision')},
            'kind':'ui_state','phase':'dialogue_selection_progress',
            'changed_fields':{'dialogue_program_revision':dialogue_fingerprint(snapshot)}}


def quest_action_owner(snapshot,frame,node,receipt=None):
    """Join current action context, never a condition or desired target alone.

    AutoPlayTarget is the current server target, not a map CTA argument or a
    locally sent receipt. Conditions remain constraints on the join. The raw
    DialogueId is retained separately and never cast to QuestInstanceId.
    """
    from .arbeit_operations import _fresh
    active=frame.get('current_autoplay') or {};now={**frame,'now':frame.get('captured_at')}
    if (active.get('source')!='current_root_AutoPlayComponent_server_target_and_active_fields'
        or any(active.get(k) is not True for k in ('confirmed','owner_verified','active'))
        or active.get('process_started_at')!=frame.get('process_started_at') or not _fresh(active,now)
        or active.get('target',{}).get('kind')!='quest_instance'):
        return None,['current_active_server_quest_autoplay_required']
    rule=dialogue_rules()['static_name_binding'];source=snapshot.get('static_name_binding',{})
    if source.get('verified') is not True or source.get('raw_sha256')!=rule['raw_sha256']:
        return None,['current_QDBLPreloadData_asset_range_revision_required']
    name=rule['names'].get(str(node.get('quest_id')))
    if not name:return None,['pinned_static_quest_name_missing']
    player=snapshot['player'];rep=snapshot['replicated'];play=rep['play_data'];npc=player['current_npc_id']
    accept=node.get('op')=='dialogue.accept'
    required_action='AcceptQuestDialogueActionData' if accept else 'AchieveQuestObjectiveDialogueActionData'
    prompt='AcceptQuest' if accept else 'AchieveQuestObjective'
    if play.get('action',{}).get('type')!=required_action or rep.get('prompt_type',{}).get('name')!=prompt:
        return None,['current_exact_quest_action_and_prompt_required']
    condition_type='QuestAcceptableDialogueConditionData' if accept else 'QuestObjectiveWaitingForAchieveDialogueConditionData'
    constraints=[c for c in play.get('conditions',[]) if c.get('quest_name') is not None]
    # Several quest conditions may describe prerequisites. That ambiguous case
    # needs the missing server action context; do not select by desired title.
    if len(constraints)!=1 or constraints[0].get('type')!=condition_type or constraints[0].get('quest_name')!=name:
        return None,['unique_current_action_quest_condition_required']
    condition=constraints[0]
    if accept:
        if frame.get('quest_state_read') and frame['quest_state_read'].get('ok') is not True:
            return None,['current_owned_quest_collection_required']
        if any(row.get('triggered_by_quest_acceptable') is not True for row in (player,rep)):
            return None,['both_current_acceptable_dialogue_trigger_flags_required']
        rows=[o for o in frame.get('offers',[]) if o.get('quest_id')==node.get('quest_id')
            and o.get('instance_id')==active['target'].get('instance_id')
            and o.get('eligible') is True and o.get('conditions_met') is True
            and o.get('process_started_at')==frame.get('process_started_at') and _fresh(o,now)]
        if len(rows)!=1:return None,['current_acceptable_autoplay_occurrence_not_unique']
        current=rows[0];giver=current.get('giver_definition') or {}
        rules=dialogue_rules();version=current.get('source_version') or {}
        if (current.get('source')!='current-root-typed-quest-collections'
            or any(current.get(k) is not True for k in ('confirmed','eligibility_verified','available_candidate'))
            or current.get('accept_time') not in (None,0)
            or not isinstance(version,dict)
            or any(version.get(k)!=rules[k] for k in ('game_assembly_sha256','metadata_sha256'))):
            return None,['current_verified_waiting_definition_required']
        if (giver.get('kind')!='npc' or giver.get('declared_type')!='Shared.Symbols.NPCSheetId'
            or type(giver.get('npc_id')) is not int or (giver['npc_id']&0xffffffff)!=(npc&0xffffffff)):
            return None,['current_offer_sender_and_dialogue_npc_mismatch']
    else:
        from .dialogue_ownership import current_report_occurrence
        if not current_report_occurrence(frame,node,receipt):
            return None,['current_report_accepted_occurrence_required']
        current=frame.get('current_quest') or {}
        if (current.get('source')!='current-root-typed-quest-collections' or current.get('confirmed') is not True
            or current.get('process_started_at')!=frame.get('process_started_at') or not _fresh(current,now)
            or not current.get('accept_time') or current.get('quest_id')!=node.get('quest_id')
            or current.get('instance_id')!=active['target'].get('instance_id')):
            return None,['current_accepted_autoplay_occurrence_required']
        checks=((current.get('step_id'),node.get('step_id')),(condition.get('step_id'),node.get('step_id')),
            (condition.get('objective_index'),node.get('objective_index')),
            (active.get('target_quest_step_index'),current.get('step_index')),
            (active.get('target_quest_objective_index'),node.get('objective_index')))
        if any(a is None or b is None or a!=b for a,b in checks):return None,['current_report_step_objective_mismatch']
    owner={k:current.get(k) for k in ('quest_id','instance_id','accept_time','step_id','objective_index')}
    if not accept:owner.update(step_id=node['step_id'],objective_index=node['objective_index'])
    owner.update(node_id=node['id'],npc_id=npc,npc=str(npc),ownership_verified=True,action_owner_verified=True,
        confirmed=True,action='accept' if accept else 'report',quest_name=name,
        static_name_binding_kind=rule['kind'],source_revision=snapshot.get('source_version'),
        observed_at=min(snapshot['observed_at'],active['observed_at'],current['observed_at']),
        session_id=frame.get('session_id'),character_id=frame.get('character_id'),process_started_at=frame.get('process_started_at'),
        source='current owned dialogue action plus server quest autoplay and occurrence constraints',
        ownership_basis=dialogue_rules()['ownership_rules']['accept' if accept else 'report'],
        giver={'npc_id':npc,'source':'current owned dialogue NPC joined to current offer sender'} if accept else None)
    owner.update(ownership_scope='waiting_instance' if accept else 'accepted_occurrence',
        accepted_occurrence=not accept)
    if accept:owner['waiting_instance_id']=current.get('instance_id')
    return owner,[]


def current_dialogue_effect(snapshot,frame,node,owner):
    """Current field proof; root compares it with an earlier send receipt.

    A closed/replaced dialogue is UI progress only, never quest completion.
    """
    if (snapshot.get('source')!='current-owned-dialogue-roots' or snapshot.get('stable') is not True
        or str(snapshot.get('identity',{}).get('character_id'))!=str(frame.get('character_id'))
        or snapshot.get('process_started_at')!=frame.get('process_started_at')
        or owner.get('node_id')!=node.get('id') or owner.get('quest_id')!=node.get('quest_id')):return None
    return {**{k:owner.get(k) for k in ('node_id','quest_id','instance_id','accept_time','step_id','objective_index','source_revision')},
            'kind':'ui_state','phase':'dialogue_selection_progress','source':'current-owned-dialogue-roots',
            'observed_at':snapshot['observed_at'],'session_id':frame.get('session_id'),'character_id':frame.get('character_id'),
            'process_started_at':snapshot['process_started_at'],'confirmed':True,'owner_verified':True,
            'fields':{'dialogue_revision':dialogue_fingerprint(snapshot,include_program=False),
                      'dialogue_program_revision':dialogue_fingerprint(snapshot),'dialogue_present':snapshot.get('present')},
            'quest_completion_inferred':False}


class DialogueBindingReader:
    def __init__(self,*,enabled=False,session_factory=DialogueBindingSession):
        self.enabled=enabled;self.session_factory=session_factory

    def read(self,*,frame,node,current_ui,receipt=None):
        if not self.enabled:return {'missing':['dialogue_read_disabled'],'action_binding':None,'ui_effect_proofs':[]}
        pid=frame.get('pid',(frame.get('window') or {}).get('pid'))
        if type(pid) is not int or pid<=0:return {'missing':['bound_dialogue_pid_required'],'action_binding':None,'ui_effect_proofs':[]}
        try:
            with self.session_factory(pid) as session:snapshot=session.current_dialogue()
        except (OSError,ValueError,RuntimeError) as exc:
            return {'missing':[str(exc)],'action_binding':None,'ui_effect_proofs':[]}
        result={'dialogue_snapshot':snapshot,'missing':validate_current_dialogue(snapshot,frame),'action_binding':None,
                'ui_effect_proofs':[],'live_verified':False}
        pending=receipt or {}
        planner=pending.get('planner_receipt')
        previous=(planner if isinstance(planner,dict) else pending).get('expected_effect') or {}
        if previous.get('kind')=='ui_state' and previous.get('phase')=='dialogue_selection_progress':
            effect=current_dialogue_effect(snapshot,frame,node,previous)
            if effect:result['ui_effect_proofs'].append(effect)
        if result['missing']:return result
        if node.get('op') not in ('dialogue.talk','dialogue.accept','dialogue.report'):
            result['missing'].append(dialogue_rules()['ownership_rule_status'])
            return result
        if node['op']=='dialogue.talk':
            owner,gaps=talk_objective_owner(snapshot,frame,node)
        else:
            from .dialogue_ownership import observe_definition_context
            result['definition_context']=observe_definition_context(snapshot,frame,node,receipt)
            owner,gaps=quest_action_owner(snapshot,frame,node,receipt)
        if not owner and node['op'] in ('dialogue.accept','dialogue.report'):
            from .direct_dialogue_request import build_direct_dialogue_request
            request=build_direct_dialogue_request(snapshot,frame,node,current_ui,pending=receipt)
            result['legacy_owner_missing']=gaps
            result.update(request)
            return result
        result['missing'].extend(gaps)
        if not owner:return result
        # Restricted continuation path: a current Confirm prompt with exactly
        # one enabled selection. Arbitrary NPC choices/shops are not selected.
        replicated=snapshot['replicated']
        if node['op']=='dialogue.talk' and (replicated.get('prompt_type',{}).get('name')!='Confirm' or len(replicated.get('selections',[]))!=1):
            result['missing'].append('talk_requires_current_single_confirm_prompt');return result
        bindings=current_ui.get('bindings',{})
        candidates=[(target,b) for target,b in bindings.items() if target.startswith('dialogue.') and b.get('descriptor')]
        if node['op']!='dialogue.talk':
            from .arbeit_targets import ui_map
            label=ui_map()['targets'][node['op']]['label']
            candidates=[(target,b) for target,b in candidates if b['descriptor'].get('displayName')==label]
        if len(candidates)!=1:result['missing'].append('one_current_dialogue_descriptor_required');return result
        target,binding=candidates[0]
        selected,gaps=bind_current_selection(snapshot,current_ui,binding['descriptor']);result['missing'].extend(gaps)
        if not selected:return result
        owner.update(selection=selected,expected_effect=expected_dialogue_progress(snapshot,owner))
        result.update(action_binding=owner,target_id=target,selection=selected)
        if node['op']=='dialogue.accept':result['dialogue_offer']=owner
        effect=current_dialogue_effect(snapshot,frame,node,owner)
        if effect:result['ui_effect_proofs'].append(effect)
        return result
