"""Exact current quest-item acquisition as a separate material supplier.

No process/input API is used. Existing typed quest, inventory and world readers
supply facts; the existing world planner supplies guarded input proposals.
"""
from copy import deepcopy
from datetime import datetime

PHASE='quest_item.acquire'
OBSERVED_PHASE='quest_item.observed'
SCOPE=('quest_id','instance_id','accept_time','step_id','step_index','objective_index','process_started_at','session_id','character_id')
TYPE='MM.QDBL.CodeGenerated.GainFakeItemByInteractInstantPropQuestObjectiveData'


def _stamp(value):return datetime.fromisoformat(value.replace('Z','+00:00'))


def _identity(node):
    return dict(node_id=node['id'],requirement=deepcopy(node['params']['material_requirement']))


def _event(node,snapshot,program):
    from .arbeit_operations import _fresh
    from .objective_binding import current_objective_occurrence
    native=program.get('typed_effect') or {}
    # Graph binding deliberately filters out sibling objectives. Read the
    # verified current collection directly when it is supplied, retaining the
    # same source/owner checks instead of inventing a new graph requirement.
    state=snapshot.get('quest_state_read') or {}
    supplied=state.get('objective_evidence',[]) if state.get('ok') is True else snapshot.get('objective_evidence',[])
    rows=[p for p in supplied if _fresh(p,snapshot)
        and current_objective_occurrence(p,snapshot,node) and p.get('confirmed') is True
        and p.get('semantics')=='current_quest_objective_native_IsAchieved'
        and all(p.get(k)==program.get(k) for k in SCOPE)]
    if len(rows)!=1:raise ValueError('quest_item_current_exact_native_event_required')
    row=rows[0]
    if (native.get('confirmed') is not True or type(row.get('complete')) is not bool
            or type(row.get('count')) is not int or row['count']<0
            or type(row.get('goal')) is not int or row['goal']<=0
            or row.get('source_version')!=program.get('source_version')
            or any(row.get(k)!=native.get(k) for k in ('complete','count','goal'))):
        raise ValueError('quest_item_current_native_event_and_program_disagree')
    return deepcopy(row)


def prepare_quest_item_supply(node,snapshot,receipt=None):
    """Return an observation-only node; never replace the material goal.

    `observation_node` retains the material node ID but scopes world observations
    to the exact acquisition objective. The immutable parent requirement and
    acquisition occurrence remain separately bound in every receipt.
    """
    from .material_supply import make_material_hold_request,validate_material_request
    from .world_target_observer import owned_instant_prop_program
    result=dict(applicable=False,ready=False,status='waiting',missing=[],observation_node=None,
        material_complete=False,native_quest_effect_supplied=False,game_inputs=0)
    if (node.get('op')!='inventory.hold' or node.get('params',{}).get('item',{}).get('table_domain')!='QuestFakeItemSheet'
            or 'material_requirement' not in node.get('params',{})):return result
    result['applicable']=True
    try:
        request=make_material_hold_request(node,snapshot)
        if not validate_material_request(node,snapshot,request):
            raise ValueError('quest_item_current_material_request:'+','.join(request.get('missing') or ['invalid']))
        result.update(material_request=request,material_complete=request['current_inventory_evidence']['available_quantity']>=request['required_quantity'])
        saved=(receipt or {}).get('child_receipt') or {}
        if receipt and (receipt.get('phase') not in (PHASE,OBSERVED_PHASE) or saved.get('material_identity')!=_identity(node)
                or receipt.get('node_id')!=node['id'] or receipt.get('quest_id')!=node['quest_id']
                or any(receipt.get(k)!=snapshot.get(k) for k in ('session_id','character_id'))):
            raise ValueError('quest_item_original_material_requirement_or_receipt_changed')
        candidates=[]
        for program in snapshot.get('objective_programs',[]):
            if (program.get('data') or {}).get('type')!=TYPE:continue
            candidate=dict(id=node['id'],op='objective.inspect',quest_id=node['quest_id'],step_id=node['step_id'],
                objective_index=program.get('objective_index'),params={})
            try:
                actual,normalized=owned_instant_prop_program(candidate,snapshot)
                output=normalized['operands'].get('output_item') or {}
                if output.get('table_domain')!='QuestFakeItemSheet' or output.get('id')!=request['item']['item_id']:continue
                scope={k:actual[k] for k in SCOPE};parent=request['parent_scope']
                if any(scope[k]!=parent.get(k) for k in SCOPE if k!='objective_index'):continue
                binding=dict(scope=scope,data_type=TYPE,source_version=deepcopy(actual['source_version']),
                    output_item_id=output['id'],output_domain='QuestFakeItemSheet',
                    instant_prop_id=normalized['operands']['instant_prop']['id'],
                    goal_counter=normalized['operands']['goal_count']['value'])
                event=_event(candidate,snapshot,actual)
                if saved.get('acquisition_binding') and saved['acquisition_binding']!=binding:continue
                candidate['params']['quest_item_acquisition']=deepcopy(binding)
                candidates.append(dict(observation_node=candidate,acquisition_binding=binding,event=event,
                    source_refs=[deepcopy(request['item']['source_material_binding']['source_ref']),
                        'data/arbeit-quest-program/source-schema.json','data/arbeit-world-object.json']))
            except (ValueError,KeyError,TypeError):continue
        if not candidates:
            # Stock can close an unsent material need. It cannot erase a sent
            # action if the original acquisition program/effect disappeared.
            if result['material_complete'] and not receipt:return dict(result,status='complete')
            raise ValueError('quest_item_same_occurrence_exact_active_acquisition_program_required')
        if len(candidates)!=1:raise ValueError('quest_item_ambiguous_current_acquisition_objective')
        result.update(candidates[0]);result.update(ready=True,status='ready')
        if result['event']['complete'] and not result['material_complete']:
            result.update(status='exhausted',missing=['quest_item_native_event_ended_but_usable_material_stock_short'])
        return result
    except (ValueError,KeyError,TypeError,AttributeError) as exc:return dict(result,missing=[str(exc)])


def _sent_source_actions(pending,saved):
    """Validate recorded dispatch against the saved proposal, not current stock.

    An unknown/partly dispatched tape remains pending. A fully described unknown
    single attempt can be reconciled by a later exact native effect, never resent.
    """
    source=saved.get('source_proposal')
    if not isinstance(source,dict) or pending.get('proposal')!=source:
        raise ValueError('quest_item_saved_source_proposal_required')
    actions=source.get('actions') or ([source['action']] if source.get('action') else [])
    if saved.get('action_kind')!=(source.get('action') or {}).get('kind'):
        raise ValueError('quest_item_saved_action_kind_changed')
    envelope=pending.get('input_receipt') or {};rows=envelope.get('receipts') or []
    if (envelope.get('status') not in ('sent_unconfirmed','unknown') or not actions
            or len(rows)!=len(actions)):
        raise ValueError('quest_item_complete_dispatch_ledger_required')
    latest=None
    for index,(action,row) in enumerate(zip(actions,rows)):
        command=row.get('command') or {}
        if (row.get('status') not in ('sent_unconfirmed','unknown') or row.get('attempted') is not True
                or row.get('operation_id')!=pending['operation_id']+':'+str(index)
                or command.get('kind')!=action.get('kind')
                or command.get('target_id')!=action.get('role','authored-key')):
            raise ValueError('quest_item_original_dispatched_command_required')
        point=action.get('point',action.get('start'))
        if point is not None and [command.get('x'),command.get('y')]!=list(point):
            raise ValueError('quest_item_dispatched_point_changed')
        if action.get('key') is not None and command.get('key')!=action['key']:
            raise ValueError('quest_item_dispatched_key_changed')
        if str(action.get('kind','')).startswith('pointer_'):
            expected=action.get('probe_context')
            if (not expected or command.get('probe_context')!=expected or row.get('target_binding')!=expected
                    or command.get('input_epoch')!=expected.get('input_epoch')
                    or row.get('input_epoch')!=expected.get('input_epoch')):
                raise ValueError('quest_item_dispatched_pointer_binding_changed')
        at=_stamp(row.get('dispatch_finished_at') or row['observed_at'])
        if at.tzinfo is None or (latest and at<latest):
            raise ValueError('quest_item_ordered_dispatch_time_required')
        latest=at
    return latest


def _project(snapshot,receipt):
    """Expose only the saved inner intent to existing UI/ledger reconciliation."""
    projected=deepcopy(snapshot);saved=(receipt or {}).get('child_receipt') or {}
    inner=deepcopy(saved.get('inner_receipt'));pending=projected.get('pending_input') or {}
    if receipt and receipt.get('phase')==PHASE:
        if (pending.get('planner_receipt')!=receipt or pending.get('status') not in ('sent_unconfirmed','unknown')
                or not pending.get('operation_id') or not isinstance(inner,dict)):
            raise ValueError('quest_item_owned_pending_input_ledger_required')
        if pending.get('status')=='unknown' and (pending.get('input_receipt') or {}).get('attempted') is not True:
            raise ValueError('quest_item_unknown_attempt_requires_reconciliation')
        _sent_source_actions(pending,saved)
        # The journal's dispatch result is authoritative. A planner receipt is
        # intentionally still 'prepared' after GraphExecutor persists intent.
        inner['dispatch_state']=pending['status']
        pending['planner_receipt']=deepcopy(inner)
    elif receipt:
        if not pending or pending.get('status')!='effect_confirmed' or pending.get('planner_receipt')!=receipt:
            raise ValueError('quest_item_observed_transition_ledger_changed')
        resolved=pending.get('resolved_input') or {};previous=resolved.get('planner_receipt') or {}
        previous_saved=previous.get('child_receipt') or {};proofs=saved.get('resolution_evidence') or []
        if (not proofs or pending.get('intermediate_effect_evidence')!=proofs or previous.get('phase')!=PHASE
                or any(previous_saved.get(k)!=saved.get(k) for k in ('material_identity','acquisition_binding','source_proposal'))):
            raise ValueError('quest_item_original_resolved_input_and_effect_required')
        _sent_source_actions(resolved,previous_saved)
        if inner is None:projected.pop('pending_input',None)
        elif inner.get('dispatch_state')=='effect_confirmed':pending['planner_receipt']=deepcopy(inner)
        else:raise ValueError('quest_item_observed_inner_effect_required')
    elif pending:raise ValueError('quest_item_foreign_pending_input_preserved')
    return projected,inner


def project_quest_item_observation(node,snapshot,prepared,receipt=None,*,frame=None):
    """Project current observations for the existing world/map/UI providers.

    No timestamps are refreshed and no node/ledger is changed in the caller.
    ``frame`` supplies physical geometry; current facts and the inner pending
    receipt come from ``snapshot``. The material parent stays separately saved.
    """
    from .game_observer import current_pointer_probe_receipt
    from .operation_binding import bind_operation
    result=dict(ready=False,missing=[],observation_node=None,snapshot=None,frame=None,inner_receipt=None)
    try:
        pending=snapshot.get('pending_input') or {}
        receipt=receipt if receipt is not None else pending.get('planner_receipt')
        verified=prepare_quest_item_supply(node,snapshot,receipt)
        if not verified.get('ready') or any(verified.get(k)!=prepared.get(k) for k in
                ('observation_node','acquisition_binding','material_request','event')):
            raise ValueError('quest_item_current_preparation_changed')
        projected,inner=_project(snapshot,receipt)
        observed=deepcopy(verified['observation_node'])
        current=bind_operation(observed,projected,inner)
        if current.get('status')!='bound':
            raise ValueError('quest_item_current_typed_operation_required')
        material=dict(node=deepcopy(node),pending_input=deepcopy(snapshot.get('pending_input')),
            material_requirement=deepcopy(node['params']['material_requirement']),
            acquisition_binding=deepcopy(verified['acquisition_binding']))
        projected.update(operation_binding=deepcopy(current['binding']),quest_item_material_context=material,
            world_object_node=observed,pointer_probe_receipt=current_pointer_probe_receipt(projected.get('pending_input')))
        projected_frame=deepcopy(frame if frame is not None else snapshot.get('current_frame') or {})
        if (projected_frame.get('process_started_at') is not None and
                projected_frame['process_started_at']!=verified['acquisition_binding']['scope']['process_started_at']):
            raise ValueError('quest_item_frame_process_lifetime_changed')
        # Preserve geometry/other real frame observations, but never retain a
        # foreign quest/pending proof from a different projected observation.
        keys=('now','current_quest','quest_state_read','objective_programs','objective_evidence',
            'current_world_targets','objective_inventory','input_owner','pending_input','operation_binding',
            'world_object_node','pointer_probe_receipt','quest_item_material_context',
            'session_id','character_id')
        for key in keys:
            if key in projected:projected_frame[key]=deepcopy(projected[key])
            else:projected_frame.pop(key,None)
        return dict(result,ready=True,observation_node=observed,snapshot=projected,
            frame=projected_frame,inner_receipt=inner)
    except (ValueError,KeyError,TypeError,AttributeError) as exc:return dict(result,missing=[str(exc)])


def plan_quest_item_acquisition(node,snapshot,receipt=None):
    from .arbeit_operations import _waiting,_complete,plan_operation
    wait=lambda reason,missing=None:_waiting(node,reason,missing or [reason],receipt)
    prepared=prepare_quest_item_supply(node,snapshot,receipt)
    if not prepared['applicable']:return None
    if not prepared['ready']:
        if prepared['status']=='complete':return _complete(node,prepared['material_request'],receipt)
        return wait('quest_item_supply_unavailable',prepared['missing'])
    try:
        projected,inner=_project(snapshot,receipt);saved=(receipt or {}).get('child_receipt') or {}
        event=prepared['event'];baseline=saved.get('event_baseline') or {};progress=None
        if inner and receipt.get('phase')==PHASE:
            if (any(event.get(k)!=baseline.get(k) for k in SCOPE) or event['goal']!=baseline.get('goal')
                    or event['count']<baseline.get('count',0)):
                return wait('quest_item_native_event_baseline_changed')
            after=_sent_source_actions(snapshot['pending_input'],saved)
            if (saved.get('action_kind')=='pointer_click' and _stamp(event['observed_at'])>after
                    and _stamp(event['observed_at'])>_stamp(receipt['observed_at'])
                    and (event['count']>baseline['count'] or event['complete'] is True and baseline.get('complete') is False)):
                progress=event
        if prepared['material_complete'] and (not inner or receipt.get('phase')==OBSERVED_PHASE):
            completed=_complete(node,prepared['material_request'],receipt)
            if receipt:
                completed['effect_evidence'].extend(deepcopy(saved.get('resolution_evidence') or []))
                completed['prior_effect_confirmed']=True
            return completed
        if progress:
            if prepared['material_complete']:
                result=_complete(node,prepared['material_request'],receipt);result['effect_evidence'].append(progress)
                result['prior_effect_confirmed']=True;return result
            next_receipt=deepcopy(receipt);next_receipt.update(phase=OBSERVED_PHASE,dispatch_state='effect_confirmed',observed_at=event['observed_at'])
            next_receipt['child_receipt']['last_observed_event']=deepcopy(event)
            next_receipt['child_receipt']['inner_receipt']=None
            next_receipt['child_receipt']['resolution_evidence']=[deepcopy(event)]
            return dict(status='transition',reason='quest_item_native_event_observed_material_still_short',
                action=None,proposal=None,effect_evidence=[progress],next_receipt=next_receipt,
                prior_effect_confirmed=True,sent_to_game=False,missing=[])
        if prepared['status']=='exhausted':return wait(prepared['missing'][0])
        result=plan_operation(prepared['observation_node'],projected,inner)
        if result.get('status')=='transition':
            proofs=result.get('effect_evidence') or []
            if not receipt or not proofs or (result.get('next_receipt') or {}).get('dispatch_state')!='effect_confirmed':
                return wait('quest_item_owned_intermediate_transition_required')
            successor=deepcopy(receipt)
            successor.update(phase=OBSERVED_PHASE,dispatch_state='effect_confirmed',
                observed_at=result['next_receipt']['observed_at'])
            successor['child_receipt'].update(inner_receipt=deepcopy(result['next_receipt']),
                last_observed_event=deepcopy(event),resolution_evidence=deepcopy(proofs))
            result.update(next_receipt=successor,prior_effect_confirmed=True,
                material_goal_separate_from_native_event=True)
            result.pop('operation_binding',None)
            return result
        if prepared['material_complete']:
            proofs=result.get('intermediate_effect_evidence') or result.get('effect_evidence')
            if result.get('prior_effect_confirmed') and proofs:
                completed=_complete(node,prepared['material_request'],receipt)
                completed['effect_evidence'].extend(deepcopy(proofs));completed['prior_effect_confirmed']=True
                return completed
            from .wait_contract import effect_wait_contract
            result=wait('quest_item_stock_ready_but_owned_input_effect_unconfirmed')
            # Only this verified owned-input boundary needs another observation.
            # Keep the wrapped acquisition receipt; stock is not its effect.
            result['wait_contract']=effect_wait_contract(receipt,family='quest_item_acquisition')
            return result
        if result.get('status')!='action':
            # Acquisition completion is never completion of the material need.
            return wait('quest_item_native_event_ended_but_usable_material_stock_short' if result.get('status')=='complete'
                else result.get('reason','quest_item_current_world_input_required'),result.get('missing'))
        if receipt and receipt.get('phase')==OBSERVED_PHASE:
            observed=saved.get('last_observed_event') or {}
            if (any(observed.get(k)!=event.get(k) for k in SCOPE) or observed.get('goal')!=event['goal']
                    or type(observed.get('count')) is not int or observed['count']>event['count']):
                return wait('quest_item_observed_event_continuation_changed')
            proofs=saved.get('resolution_evidence') or []
            if not proofs:return wait('quest_item_observed_resolution_evidence_required')
            result.update(prior_effect_confirmed=True,intermediate_effect_evidence=deepcopy(proofs))
        child=dict(material_identity=_identity(node),acquisition_binding=deepcopy(prepared['acquisition_binding']),
            observation_node=deepcopy(prepared['observation_node']),inner_receipt=deepcopy(result['next_receipt']),
            event_baseline=event,stock_baseline=deepcopy(prepared['material_request']['current_inventory_evidence']),
            source_proposal=deepcopy(result['proposal']),
            action_kind=(result.get('proposal',{}).get('action') or {}).get('kind'))
        outer=dict(node_id=node['id'],quest_id=node['quest_id'],session_id=snapshot['session_id'],character_id=snapshot['character_id'],
            observed_at=result['next_receipt']['observed_at'],phase=PHASE,dispatch_state='prepared',
            goal_scope=deepcopy(prepared['material_request']['parent_scope']),child_receipt=child)
        result['next_receipt']=outer;result.pop('operation_binding',None)
        result['material_goal_separate_from_native_event']=True
        return result
    except (ValueError,KeyError,TypeError,AttributeError) as exc:return wait(str(exc))
