"""Owned current dialogue selection requests; later quest effects stay separate.

Pure joins and an opt-in shared-session reader. No packet construction, process
opening or input. A quest condition is a selection policy, never an effect owner.
"""
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import struct

from .paths import root

VERSION = ('game_assembly_sha256', 'metadata_sha256')
OWNER = ('session_id', 'character_id', 'process_started_at')
NATIVE_EFFECT_BASIS = 'pinned_IsAchieved_GetGoal_IsStackableObjective_and_current_vtable_data_owner'


@lru_cache(maxsize=1)
def request_source():
    return json.loads((root()/'data/r6-direct-dialogue/request-contract.json').read_text(encoding='utf8'))


def _hash(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
        separators=(',', ':'), allow_nan=False).encode()).hexdigest()


def _request_fields(session, pointer, shape):
    # Shared fields() caches by class/type, not by the requested field subset.
    # A prior SingularEntity subset must not mask this new concrete root field.
    previous = getattr(session, '_validated', None)
    session._validated = {}
    try:
        return session.fields(session.reader.u64(pointer), shape)
    finally:
        if previous is None:
            del session._validated
        else:
            session._validated = previous


def observe_direct_request_component(session, context):
    """Read the actual concrete component in the already owned SingularEntity.

    There is no IDialogueActionComponent field in this client. The caller must
    perform its final character/root/watch revalidation after this observation.
    """
    source = request_source()
    if any(session.plan.get(k) != source[k] for k in VERSION):
        raise ValueError('direct_request_component_source_revision_changed')
    entity = context['chain'][3]
    shapes = {row['name']: row for row in source['classes']}
    root_shape = shapes['SingularEntity']
    _request_fields(session, entity, root_shape)
    field = root_shape['fields'][0]
    if field['name'] != 'DialogueActionComponent' or field['type'].get('definition_index') != 29178:
        raise ValueError('direct_request_component_root_shape_changed')
    pointer = struct.unpack('<Q', session.watch(entity+field['offset'], 8))[0]
    result = dict(source='current_owned_SingularEntity_DialogueActionComponent',
        source_version={k:source[k] for k in VERSION}, source_revision=source['source_revision'],
        observed_at=datetime.now(timezone.utc).isoformat(), present=bool(pointer),
        caller_final_stability_check_required=True, game_reads_opened=0, game_inputs=0)
    if not pointer:
        return dict(result, action_requested_index=None)
    shape = shapes['DialogueActionComponent']
    _request_fields(session, pointer, shape)
    field = next(f for f in shape['fields'] if f['name']=='<ActionRequestedIndex>k__BackingField')
    value = struct.unpack('<i', session.watch(pointer+field['offset'], 4))[0]
    return dict(result, action_requested_index=value)


def _planner(receipt):
    return (receipt or {}).get('planner_receipt', receipt or {})


def _rows(receipt):
    return ((receipt or {}).get('input_receipt') or {}).get('receipts', [])


def _only_probe(receipt):
    rows = _rows(receipt)
    return bool(rows) and all(r.get('command', {}).get('kind') == 'pointer_probe' for r in rows)


def _complete_action_structure(structure):
    """A stable hash cannot cover an unread current action payload.

    Absent optional/reference branches have no child payload to decode. Every
    present child, including the unselected continuation, must be fully read so
    this request does not advertise a complete guard over unknown operands.
    """
    pending=[(structure,0)];count=0
    while pending:
        row,depth=pending.pop();count+=1
        if (count>128 or depth>16 or not isinstance(row,dict)
                or row.get('decoded') is not True or row.get('missing')
                or row.get('unread_fields') != []
                or not isinstance(row.get('explicit_operands'),dict)
                or not row.get('type') or row.get('namespace')!='MM.QDBL.CodeGenerated'
                or not isinstance(row.get('branches'),list)):
            return False
        for branch in row['branches']:
            if (not isinstance(branch,dict) or not isinstance(branch.get('field'),str)
                    or type(branch.get('present')) is not bool):
                return False
            if branch['present']:
                pending.append((branch,depth+1))
            elif set(branch)-{'field','present','index'}:
                return False
    return True


def build_direct_dialogue_request(dialogue, frame, node, current_ui, pending=None):
    """Bind a current affirmative UI selection, without asserting quest causality.

    Policy: the requested quest has one exact current typed constraint, the
    current NPC agrees, and the before-goal waiting/accepted scope is known.
    This narrows the permitted request; it does not prove which server quest the
    request will affect. Only reconcile's later typed effect can finish a goal.
    """
    from .dialogue_binding import bind_current_selection, dialogue_program_structure
    from .dialogue_ownership import observe_definition_context, SOURCE_GAP
    from .arbeit_operations import _fresh, _stamp
    from .arbeit_targets import ui_map
    result = dict(request_binding=None, target_id=None, selection=None, missing=[])
    def fail(reason):
        return dict(result, missing=[reason])
    context = observe_definition_context(dialogue, frame, node)
    if context.get('candidate') is None or any(g != SOURCE_GAP for g in context.get('missing', [])):
        return dict(result, missing=context.get('missing', []))
    candidate = context['candidate']
    source = request_source()
    component = dialogue.get('request_component') or {}
    current = dict(frame, now=frame.get('captured_at',frame.get('now')))
    component_evidence = dict(component, **{k:frame.get(k) for k in OWNER})
    if (component.get('source') != 'current_owned_SingularEntity_DialogueActionComponent'
            or component.get('present') is not True
            or component.get('source_version') != {k:source[k] for k in VERSION}
            or component.get('source_revision') != source['source_revision']
            or not _fresh(component_evidence,current)):
        return fail('current_owned_dialogue_request_component_required')
    index = component.get('action_requested_index')
    if type(index) is not int or index >= 0:
        return fail('current_dialogue_request_already_in_flight')
    rep = dialogue['replicated']; player = dialogue['player']; play = rep['play_data']
    structures = [who['play_data'].get('direct_action_structure') or {} for who in (player,rep)]
    for structure in structures:
        if (structure.get('stable') is not True
                or structure.get('source') != 'current_declared_dialogue_action_operands'
                or structure.get('source_version') != {k:source[k] for k in VERSION}
                or structure.get('type') != play['action']['type']
                or structure.get('root_quest_target_operand_absent') is not True
                or structure.get('root_effect_target') is not None
                or not structure.get('structure_revision')):
            return fail('current_direct_action_structure_required')
        if not _complete_action_structure(structure):
            return fail('current_direct_action_structure_incomplete')
    projection = lambda s: {k:deepcopy(s.get(k)) for k in
        ('type','namespace','source_version','structure_revision')}
    if projection(structures[0]) != projection(structures[1]):
        return fail('player_replicated_direct_action_structure_changed')
    bindings = [(target,b) for target,b in current_ui.get('bindings',{}).items()
        if target.startswith('dialogue.') and b.get('descriptor',{}).get('displayName') ==
           ui_map()['targets'][node['op']]['label']]
    if len(bindings) != 1:
        return fail('one_current_affirmative_dialogue_descriptor_required')
    target, binding = bindings[0]
    selected, gaps = bind_current_selection(dialogue,current_ui,binding['descriptor'])
    if gaps:
        return dict(result,missing=gaps)
    event_id = binding['descriptor']['onClickEvent'].get('event_id')
    if type(event_id) is not int or event_id != selected['selection_index']:
        return fail('current_callback_EventId_SelectIndex_mismatch')
    scope_keys = ('quest_id','node_id','waiting_instance_id','accepted_occurrence',
        'instance_id','accept_time','step_id','step_index','objective_index','process_started_at')
    before = {k:deepcopy(candidate[k]) for k in scope_keys if k in candidate}
    if not before['accepted_occurrence']:
        if type(before.get('waiting_instance_id')) is not int:
            return fail('current_waiting_instance_identity_required')
    transaction = dict(dialogue_id=play['dialogue_id'], dialogue_index=rep['dialogue_index'],
        prompt_index=rep['prompt_index'],prompt_type=deepcopy(rep['prompt_type']),
        npc_id=player['current_npc_id'],target_object_id=player.get('target_object_id'),
        target_entity_key=deepcopy(player.get('target_entity_key')),
        selection={k:deepcopy(selected.get(k)) for k in
            ('selection_index','type','keyword_name','dialogue_id','enabled','keyword_id')},
        action_structure=projection(structures[0]),program_structure=dialogue_program_structure(play))
    owned = dict(current_dialogue_input_owned=True,quest_effect_owner_verified=False,
        transaction=transaction,transaction_id=_hash(transaction),before_scope=before,
        source='current owned dialogue request and exact UI SelectIndex',
        source_revision=source['source_revision'],client_source_version={k:source[k] for k in VERSION},
        observed_at=min((_stamp(r['observed_at']) for r in (dialogue,candidate,component))).isoformat(),
        node_id=node['id'],quest_id=node['quest_id'],operation=node['op'],target_id=target,
        **{k:frame.get(k) for k in OWNER},
        expected_effect={'kind':'typed_quest_state','transition':'accepted' if node['op']=='dialogue.accept'
            else 'reported_objective_or_previous_step_terminated','scope':deepcopy(before)},
        selection_policy={'definition_constraint':deepcopy(candidate['definition_constraint']),
            'effect_owner_inferred':False},payload_is_identity_projection=True,
        duplicate_request_allowed=False)
    if node['op']=='dialogue.report':
        baselines=[e for e in frame.get('objective_evidence',[]) if
            e.get('complete') is False and e.get('confirmed') is True and _fresh(e,current)
            and e.get('source')=='current-root-typed-quest-collections' and e.get('basis')==NATIVE_EFFECT_BASIS
            and e.get('semantics')=='current_quest_objective_native_IsAchieved'
            and e.get('source_version')==owned['client_source_version']
            and all(e.get(k)==before.get(k) for k in
                ('quest_id','instance_id','accept_time','step_id','objective_index','process_started_at'))]
        if len(baselines)==1:owned['before_objective']=deepcopy(baselines[0])
    if pending:
        old = _planner(pending).get('request_binding')
        if not _only_probe(pending):
            return fail('direct_dialogue_request_pending_no_duplicate')
        if not old or any(old.get(k)!=owned.get(k) for k in
            ('node_id','quest_id','transaction','before_scope','source_revision',*OWNER)):
            return fail('pending_direct_dialogue_probe_scope_changed')
    return dict(result,request_binding=owned,target_id=target,selection=deepcopy(selected))


def reconcile_direct_dialogue_request(node, snapshot, receipt):
    """Never resubmit an uncertain request. UI/RPC acknowledgments are not goals."""
    from .arbeit_operations import _fresh, _stamp
    binding = _planner(receipt).get('request_binding')
    if not binding:
        return {'status':'none','retry_allowed':False}
    base = dict(status='pending',retry_allowed=False,quest_effect_verified=False,
        reason='direct_dialogue_request_awaiting_exact_typed_goal_effect')
    if (binding.get('node_id')!=node.get('id') or binding.get('quest_id')!=node.get('quest_id')
            or binding.get('operation')!=node.get('op')
            or any(binding.get(k)!=snapshot.get(k) for k in OWNER)
            or binding.get('source_revision')!=request_source()['source_revision']
            or binding.get('current_dialogue_input_owned') is not True
            or binding.get('quest_effect_owner_verified') is not False):
        return dict(base,status='blocked',reason='direct_dialogue_pending_scope_or_source_changed')
    if _only_probe(receipt):
        return dict(base,status='probe_only',reason='direct_dialogue_probe_is_not_a_selection_request')
    rows = [r for r in _rows(receipt) if r.get('command',{}).get('kind') == 'pointer_click']
    if not rows:
        return dict(base,status='blocked',reason='direct_dialogue_request_dispatch_evidence_missing')
    if any(r.get('command',{}).get('target_id')!=binding.get('target_id') for r in rows):
        return dict(base,status='blocked',reason='direct_dialogue_request_dispatch_target_changed')
    # A dry-run/explicitly unattempted input cannot cause a quest transition.
    attempted = [r for r in rows if r.get('attempted') is True and
        r.get('status') in ('sent_unconfirmed','unknown')]
    if not attempted:
        return dict(base,status='blocked',reason='direct_dialogue_request_not_actually_attempted')
    try:
        after = max(_stamp(r['dispatch_finished_at']) for r in attempted)
    except (KeyError,TypeError,ValueError):
        return dict(base,status='blocked',reason='direct_dialogue_request_dispatch_time_missing')
    before = binding['before_scope']
    # GameObserver.read returns observed_at at the transaction boundary; its
    # captured_at belongs to a nested visual frame. Preserve the caller's actual
    # observation clock and every proof timestamp. Never synthesize wall time to
    # make an older observation appear current. Explicit fixture clocks remain
    # supported, but an absent/invalid clock cannot reconcile a sent request.
    clock = snapshot.get('now',snapshot.get('observed_at',snapshot.get('captured_at')))
    try:
        _stamp(clock)
    except (TypeError,ValueError):
        return dict(base,reason='direct_dialogue_current_observation_time_missing_or_invalid')
    now = dict(snapshot,now=clock)
    state_read = snapshot.get('quest_state_read')
    if state_read is not None and state_read.get('ok') is not True:
        return dict(base,reason='direct_dialogue_current_quest_read_failed')
    proofs = list(snapshot.get('state_proofs',[]))
    # Accept only the public normalizer output, not raw active records.
    if isinstance(state_read,dict):proofs += state_read.get('state_proofs',[])
    if node['op']=='dialogue.report':proofs += snapshot.get('objective_evidence',[])
    for proof in proofs:
        if (proof.get('confirmed') is not True or not _fresh(proof,now)
                or proof.get('quest_id')!=binding['quest_id']
                or proof.get('process_started_at')!=binding['process_started_at']
                or proof.get('source_version')!=binding['client_source_version']):
            continue
        try:
            if _stamp(proof['observed_at'])<=after:continue
        except (ValueError,TypeError,KeyError):continue
        success=False
        if node['op']=='dialogue.accept':
            success=(proof.get('state')=='accepted'
                and proof.get('source')=='current-root-typed-quest-collections'
                and proof.get('basis')=='native_IsAccepted_with_current_owned_ActiveQuest_and_verified_waiting_sentinel'
                and proof.get('instance_id')==before.get('waiting_instance_id')
                and type(proof.get('accept_time')) is int and proof['accept_time']>0)
        elif (proof.get('instance_id')==before.get('instance_id') and
                proof.get('accept_time')==before.get('accept_time') and proof.get('step_id')==before.get('step_id')):
            success=(proof.get('state')=='step_complete'
                and proof.get('source')=='current-root-typed-quest-collections'
                and proof.get('basis') in (
                    'native_IsQuestStepCompletedBefore_with_current_runtime_StepIds',
                    'native_IsAllStepsCompleted_and_current_runtime_StepIds'))
            # Individual objective success requires a captured false->true native
            # baseline, not the already-complete waiting-for-report objective.
            baseline=binding.get('before_objective')
            if (baseline and baseline.get('complete') is False
                    and proof.get('complete') is True and proof.get('source')==baseline.get('source')
                    and proof.get('semantics')=='current_quest_objective_native_IsAchieved'
                    and proof.get('basis')==NATIVE_EFFECT_BASIS
                    and proof.get('objective_index')==before.get('objective_index')):
                success=True
        if success:
            return dict(base,status='effect_observed',reason=None,quest_effect_verified=True,
                effect_evidence=deepcopy(proof),causal_server_mapping_inferred=False)
    return base
