"""Pure, evidence-bound typed map movement and historical gather geometry.

No process, network, window capture or input APIs are imported here.
``plan_movement(snapshot, goal, receipt=None, now=None)`` consumes the
mini27 MapMemorySession.read schema plus adapter metadata:
  observation_source, certainty='confirmed', session_id, viewport=[w,h],
  safe_rect=[left,top,width,height], controls={role:[client_x,client_y]}.
Controls are prepared current HUD/map geometry, never static NPC positions.
Goal kind: npc|marker|map_position|facility|dungeon_entrance; world_data_key,
exact npc_id/marker_id/moon_gate_id/dungeon_id/battlefield_id as appropriate.
Coordinates require explicit coordinate_space='current-map'; world deployment
positions are never accepted as UI/map coordinates. bind_movement_goal adapts
catalog targets; plan_npc_movement remains a compatibility alias.
Receipt: returned next_receipt, committed by caller BEFORE dispatch. Preserve
it on uncertain delivery; clear it only for an explicit NOT-SENT outcome.
An action proposal has kind, role, point/delta/start/end, receipt_phase. It
is a calculation candidate, not live authorization. The input adapter must
revalidate the owner, snapshot token, viewport, foreground and stop flag.
Status action/waiting/arrived never means a quest was accepted or completed.
``now`` only rejects stale observations; elapsed time never proves outcome.
"""
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import math

from .paths import root
from .vendor.ui_layout import Rect


class MovementEvidenceUnavailable(ValueError):
    pass


def movement_definition():
    return json.loads((root() / 'data/arbeit-movement-map.json').read_text(encoding='utf-8'))


def caitin_goal():
    data = movement_definition()
    npc = data['npc']
    return {'npc_id': npc['npc_id'], 'world_data_key': npc['world_data_key'],
            'title': npc['name'], 'region_names': data['region_names']}


@lru_cache(maxsize=1)
def _static_catalog():
    reference=movement_definition()['movement_catalog_ref']
    # This is a bundled snapshot, not a source-tree reference. Verify bytes.
    raw=(root()/'data/snapshots/movement-catalog.json').read_bytes()
    if hashlib.sha256(raw).hexdigest()!=reference['sha256']:
        raise MovementEvidenceUnavailable('bundled_movement_catalog_hash_mismatch')
    return json.loads(raw)


def movement_catalog_summary():
    data=_static_catalog()
    counts={key:len(data[key]) for key in ('destinations','npcs','game_spaces','map_configs')}
    entrances={'Entrance_Dungeon','Entrance_Battlefield','Raid_Entrance'}
    counts.update(npc_deployments=sum(r['kind']=='npc' for r in data['destinations']),
                  entrance_markers=sum(r.get('category') in entrances for r in data['destinations']),
                  facility_bindings=sum(bool(r.get('facility_id')) for r in data['destinations']))
    return dict(counts,current_selectable=False,game_tested=False,
                facility_gap='static catalog has no facility_id column; exact current identity binding required',
                coordinate_goal='explicit current-map coordinate only, never static world position')


def static_destination_goal(destination_id,*,kind):
    data=_static_catalog()
    rows=[r for r in data['destinations'] if r['id']==destination_id]
    if len(rows)!=1:
        raise MovementEvidenceUnavailable('static_destination_not_unique')
    result=destination_goal(rows[0],kind=kind)
    result['region_names']=sorted({r['name'] for r in data['game_spaces'] if r['world_data_key']==result['world_data_key']})
    return result


def _normalise_goal(goal):
    """Exact identities only: no title search or world-to-map conversion."""
    if not isinstance(goal,dict) or not isinstance(goal.get('world_data_key'),str) or not goal['world_data_key']:
        raise MovementEvidenceUnavailable('missing_fields:world_data_key')
    result=deepcopy(goal)
    kind=result.get('kind','npc' if 'npc_id' in result else None)
    if kind not in ('npc','map_position','marker','facility','dungeon_entrance'):
        raise MovementEvidenceUnavailable('unsupported_movement_goal_kind')
    result['kind']=kind
    if kind=='map_position':
        if result.get('coordinate_space')!='current-map':
            raise MovementEvidenceUnavailable('missing_fields:coordinate_space=current-map,map_position')
        result['map_position']=list(_pair(result.get('map_position')))
    elif kind=='npc':
        if type(result.get('npc_id')) is not int or result['npc_id']<=0:
            raise MovementEvidenceUnavailable('missing_fields:npc_id')
    else:
        identities=[key for key in ('npc_id','dungeon_id','battlefield_id','marker_id','moon_gate_id') if result.get(key) is not None]
        if not identities:
            raise MovementEvidenceUnavailable('missing_fields:exact npc_id|dungeon_id|battlefield_id|marker_id|moon_gate_id')
        for key in identities:
            value=result[key]
            if key in ('npc_id','dungeon_id','moon_gate_id'):
                valid=type(value) is int and value>0
            else:
                valid=isinstance(value,str) and bool(value.strip())
            if not valid:
                raise MovementEvidenceUnavailable('invalid_movement_identity:'+key)
    return result


def destination_goal(destination,*,kind):
    """Bind a static catalog row's exact identity; never its world position.

    Caller chooses a supported semantic kind based on its requirement/catalog.
    A generic marker cannot be silently promoted to a facility or dungeon.
    Returned rows remain subject to CURRENT selectable marker and CTA checks.
    """
    if not isinstance(destination,dict):
        raise MovementEvidenceUnavailable('destination_row_required')
    result={'kind':kind,'world_data_key':destination.get('world_data_key'),
            'title':destination.get('name'), 'source_destination':destination.get('id'),
            'source':destination.get('source')}
    for key in ('npc_id','dungeon_id','battlefield_id'):
        if destination.get(key) is not None:
            result[key]=destination[key]
    if not any(k in result for k in ('npc_id','dungeon_id','battlefield_id')) and destination.get('symbol'):
        result['marker_id']=destination['symbol']
    # Static map-coordinate projection is intentionally absent.
    return _normalise_goal(result)


def _target_sources(target):
    resolution=target.get('identity_resolution') or {}
    return [deepcopy(resolution[key]) for key in ('source_ref','table_source_ref')
            if isinstance(resolution.get(key),dict)]


def _current_exact_npc_world(snapshot,npc_id):
    """Only fresh confirmed local-map markers can narrow a missing/ambiguous region."""
    if not isinstance(snapshot,dict) or snapshot.get('certainty')!='confirmed' or not snapshot.get('observation_source'):
        return None
    try:
        age=(datetime.now(timezone.utc)-_time(snapshot['captured_at'])).total_seconds()
        if not -.5<=age<=5 or snapshot.get('map_ready') is not True or snapshot['pose']['map_type']==1:
            return None
        rows=[m for m in snapshot['markers'] if (m.get('detail') or {}).get('npc_id')==npc_id]
        return snapshot.get('world_data_key') if len(rows)==1 else None
    except (KeyError,TypeError,ValueError):
        return None


def resolve_descriptor_domain_values(values,source_version):
    """Resolve exact declared SheetIds against bundled current static tables.

    Symbol tables and sheet preload IDs agree independently. Channel.GameSpace
    follows the pinned native row-index decoder, not a matching symbol string.
    This interprets supplied values; it does not observe a live quest, spawn,
    coordinate, arrival or interaction target.
    """
    result=dict(available=False,domains={},missing=[],source_refs=[],
        current_instance_observed=False,coordinates=None,ready_movement_target=False,absent_domains=[])
    definition=movement_definition().get('descriptor_domains')
    if not isinstance(definition,dict):return dict(result,missing=['bundled_exact_descriptor_domains_missing'])
    if not isinstance(source_version,dict) or any(source_version.get(key)!=definition[key] for key in ('game_assembly_sha256','metadata_sha256')):
        return dict(result,missing=['descriptor_domain_table_client_version_mismatch'])
    if not isinstance(values,dict):return dict(result,missing=['descriptor.values'])
    for key,domain in definition['domains'].items():
        value=values.get(key) or {}
        optional=value.get('storage')=='QDBL.Optional'
        if optional:
            valid=(key=='ChannelName' and value.get('interpreted') is True
                   and value.get('value_type')==domain['declared_type']
                   and value.get('metadata_type_index')==domain.get('optional_metadata_type_index')
                   and type(value.get('has_value')) is bool)
            if valid and not value['has_value']:
                result['absent_domains'].append(key);continue
        else:
            valid=(value.get('storage')=='native_32bit_identifier'
                   and value.get('metadata_type_index')==domain['metadata_type_index'])
        if not valid or type(value.get('value')) is not int:
            result['missing'].append(key+'.declared_sheet_id_value');continue
        declared=value.get('value_type') if optional else value.get('declared_type',domain['declared_type'])
        if declared!=domain['declared_type'] or value.get('table_domain',domain['table']+'Sheet')!=domain['table']+'Sheet':
            result['missing'].append(key+'.declared_type_does_not_match_exact_source_domain');continue
        matches=[row for row in domain['rows'] if row['id']==value['value']]
        if len(matches)!=1:
            result['missing'].append(key+'.unknown_or_duplicate_sheet_id:'+str(value['value']));continue
        record=deepcopy(matches[0])
        record.update(declared_type=domain['declared_type'],table=domain['table'],symbol_name_resolved=True,
                      source_kind='exact static sheet ID and symbol table join',current_spawn_observed=False)
        if key=='ChannelName':
            join=definition.get('channel_game_space_join') or {}
            if not join.get('verified') or 'game_space' not in record:
                result['missing'].append('ChannelSheetData.GameSpace.source_verified_row_join');continue
            record['game_space_join_source']=deepcopy(join)
        result['domains'][key]=record
        result['source_refs'].append(deepcopy(domain['source_ref']))
    result['source_refs'].append(deepcopy(definition['exact_type_source']))
    result['available']=not result['missing'] and len(result['domains'])+len(result['absent_domains'])==2
    return result


def descriptor_movement_boundary(descriptor,current_snapshot,expected_objective,*,now=None):
    """Inspect a current owned descriptor and interpret its exact SheetIds.

    The current supplier exposes InstantPropName as an int32 identity and
    ChannelName/RegionName as Optional values. Absence is not a guessed target. Native registration proves the two
    SheetId domains, and static tables resolve symbols and Channel.GameSpace.
    Current marker objective ownership is read separately from its tuple key.
    Presentation geometry is not a spawned prop point. This function
    deliberately never returns plain-movement ready=True. The evidence is still
    useful: a missing current owner and a missing domain join are distinct.
    No per-quest IDs, region-name search, nearest point or coordinate guesses.
    """
    result=dict(ready=False,goal=None,identity_bound=False,descriptor_confirmed=False,
        live_ready=False,static_world_projection=False,raw_identifiers={},missing=[],
        source_refs=[],binding_stage='current_descriptor_unbound')
    required=('quest_id','instance_id','accept_time','step_id','step_index','objective_index')
    if not isinstance(descriptor,dict):return dict(result,missing=['current_owned_descriptor.object'])
    result['descriptor']=deepcopy(descriptor)
    if not isinstance(expected_objective,dict):return dict(result,missing=['expected_objective:'+','.join(required)])
    absent=[key for key in required if expected_objective.get(key) is None]
    if absent:return dict(result,missing=['expected_objective.'+key for key in absent])
    if any(str(descriptor.get(key))!=str(expected_objective[key]) for key in required):
        return dict(result,missing=['current_descriptor.quest_instance_accept_time_step_objective_mismatch'])
    if descriptor.get('confirmed') is not True or descriptor.get('step_binding_confirmed') is not True or descriptor.get('source')!='current-root-typed-quest-descriptor' or descriptor.get('basis')!='current_owned_QuestObjective_Data_and_AutoPlayDescriptor_references':
        return dict(result,missing=['current_descriptor.verified_current_objective_reference'])
    if not isinstance(current_snapshot,dict):return dict(result,missing=['current_map.owner'])
    try:
        if current_snapshot.get('certainty')!='confirmed' or not current_snapshot.get('observation_source'):
            raise MovementEvidenceUnavailable('current_map.confirmed_source')
        current_owner=_owner(current_snapshot)
        descriptor_owner=[descriptor.get('process_started_at'),descriptor.get('character_id'),descriptor.get('session_id')]
        if any(value is None or value=='' for value in descriptor_owner) or [str(value) for value in descriptor_owner]!=[str(value) for value in current_owner[1:]]:
            raise MovementEvidenceUnavailable('current_descriptor.map_owner_mismatch')
        at=now or datetime.now(timezone.utc)
        for stamp in (descriptor.get('observed_at'),current_snapshot.get('captured_at')):
            if not -.5<=(at-_time(stamp)).total_seconds()<=5:
                raise MovementEvidenceUnavailable('current_descriptor_or_map.stale')
        pin=movement_definition()['reader_rules']['map-memory-layout']
        versions=descriptor.get('source_version') or {}
        if any(versions.get(key)!=pin[key] for key in ('game_assembly_sha256','metadata_sha256')):
            raise MovementEvidenceUnavailable('current_descriptor.map_version_mismatch')
    except (MovementEvidenceUnavailable,ValueError,KeyError,TypeError,AttributeError) as exc:
        return dict(result,missing=[str(exc)])
    result.update(identity_bound=True,descriptor_confirmed=True,binding_stage='current_descriptor_domain_join_missing')
    auto=descriptor.get('auto_play') or {}
    data=descriptor.get('data') or {}
    if auto.get('available') is not True or auto.get('field_layout_verified') is not True or auto.get('is_current_reference') is not True:
        return dict(result,missing=['current_descriptor.auto_play.verified_current_layout'])
    result['descriptor_type']=auto.get('type')
    result['raw_identifiers']=deepcopy(auto.get('values') or {})
    result['source_refs']=[{'asset':'data/arbeit-quest-state/current-descriptors.json',
        'source':descriptor['source'],'source_version':deepcopy(versions),'observed_at':descriptor['observed_at']}]
    if auto.get('type')!='MM.QDBL.CodeGenerated.InteractionWithInstantPropAutoPlayDescriptorData':
        return dict(result,missing=['descriptor_type_to_movement_semantics:'+str(auto.get('type'))])
    values=auto.get('values') or {}
    definitions=movement_definition()['descriptor_domains']['domains']
    expected_types={key:domain['metadata_type_index'] for key,domain in definitions.items()}
    value=values.get('InstantPropName') or {}
    if (value.get('storage')!='native_32bit_identifier' or type(value.get('value')) is not int
            or not -(2**31)<=value['value']<2**31 or value.get('metadata_type_index')!=expected_types['InstantPropName']):
        return dict(result,missing=['current_descriptor.InstantPropName.native_identifier_type'])
    channel=values.get('ChannelName') or {}
    if (channel.get('storage')!='QDBL.Optional' or channel.get('interpreted') is not True
            or channel.get('value_type')!='Shared.Symbols.ChannelSheetId'
            or channel.get('metadata_type_index')!=definitions['ChannelName'].get('optional_metadata_type_index')
            or type(channel.get('has_value')) is not bool
            or channel['has_value'] and (type(channel.get('value')) is not int or not -(2**31)<=channel['value']<2**31)):
        return dict(result,missing=['current_descriptor.ChannelName.optional_identifier_type'])
    region=values.get('RegionName') or {}
    if (region.get('storage')!='QDBL.Optional' or region.get('interpreted') is not True
            or region.get('value_type')!='System.String' or type(region.get('has_value')) is not bool
            or region['has_value'] and region.get('value') is not None and not isinstance(region['value'],str)):
        return dict(result,missing=['current_descriptor.RegionName.optional_string_type'])
    data_value=(data.get('values') or {}).get('InstantPropName') or {}
    if data.get('available') is not True or data.get('field_layout_verified') is not True or data.get('is_current_reference') is not True or data.get('type')!='MM.QDBL.CodeGenerated.InteractInstantPropQuestObjectiveData' or data_value.get('value')!=values['InstantPropName']['value'] or data_value.get('metadata_type_index')!=expected_types['InstantPropName']:
        return dict(result,missing=['current_objective_data.AutoPlayDescriptor.InstantPropName_agreement'])
    interpreted=resolve_descriptor_domain_values(values,versions)
    result['domain_interpretation']=interpreted
    result['domain_interpreter_ready']=interpreted['available']
    if not interpreted['available']:
        return dict(result,missing=interpreted['missing'])
    from .vendor.map_dynamic_targets import current_objective_marker_matches,native_callback_matches
    markers=[m for m in current_snapshot.get('markers',[]) if current_objective_marker_matches(m,expected_objective)]
    callbacks=[m for m in markers if native_callback_matches(m,expected_objective['instance_id'])]
    result['current_objective_markers']=[{'marker_key':m.get('key'),'map_position':deepcopy(m.get('map_position')),
        'quest_marker_scope':deepcopy(m['detail']['quest_marker_scope']),
        'native_quest_callback_verified':m in callbacks,'coordinate_semantics':'current_presentation_marker',
        'spawned_target_position_verified':False} for m in markers]
    result['native_callback_ready']=bool(callbacks)
    result['native_callback_action_scope']='quest_instance'
    result['native_callback_consumer']='quest_autoplay.plan_quest_autoplay'
    result['binding_stage']='current_objective_presentation_marker_bound' if markers else 'declared_sheet_ids_resolved_current_objective_marker_missing'
    missing=[
        'InstantPropSheetId definition->currently activated spawned instant-prop instance->current map marker identity',
        'RegionName->verified owning world region/arrival predicate; string equality with marker_id is insufficient',
        'current spawned target position; presentation marker/region center is not a spawned prop point',
        'current plain walk permission and separate instant-prop activation/interaction evidence']
    if not markers:missing.append('current marker QuestMapMarkerKey matching current quest instance/step_index/objective_index')
    return dict(result,missing=missing)


def bind_movement_goal(target,current_snapshot=None,*,expected_objective=None):
    """Bind the static token and optionally retain current descriptor diagnostics.

    ``current_snapshot.runtime_descriptors`` contains the quest provider's
    current_descriptors rows. expected_objective must identify the exact current
    quest occurrence, step and objective; descriptors are never chosen by text.
    Static ready counts retain their descriptor-only meaning. The additional
    runtime_descriptor_boundary never promotes an unmapped descriptor to ready.
    """
    result=_bind_static_movement_goal(target,current_snapshot)
    if not isinstance(current_snapshot,dict) or 'runtime_descriptors' not in current_snapshot:return result
    descriptors=current_snapshot['runtime_descriptors']
    required=('quest_id','instance_id','accept_time','step_id','step_index','objective_index')
    boundary=dict(ready=False,goal=None,identity_bound=False,descriptor_confirmed=False,live_ready=False,
        static_world_projection=False,raw_identifiers={},source_refs=[],missing=[])
    if not isinstance(descriptors,list):
        boundary['missing']=['runtime_descriptors.array']
    elif not isinstance(expected_objective,dict) or any(expected_objective.get(key) is None for key in required):
        boundary['missing']=['expected_objective:'+','.join(required)]
        boundary['current_descriptor_count']=len(descriptors)
    else:
        matches=[row for row in descriptors if isinstance(row,dict) and all(str(row.get(key))==str(expected_objective[key]) for key in required)]
        if len(matches)!=1:
            boundary['missing']=['current_owned_descriptor_not_unique:'+str(len(matches))]
            boundary['current_descriptor_count']=len(descriptors)
        else:
            boundary=descriptor_movement_boundary(matches[0],current_snapshot,expected_objective)
    result['runtime_descriptor_boundary']=boundary
    return result


def bind_offer_movement_goal(offer,current_snapshot=None,*,now=None):
    """Current waiting QuestStaticData.Sender -> reusable NPC movement goal.

    This binds an available candidate's giver identity. It does not establish
    a currently displayed dialogue offer or report that travel opened one.
    Prefer plan_offer_autoplay when a same-instance native callback is current.
    """
    result=dict(ready=False,goal=None,missing=[],source_refs=[],live_ready=False,
                descriptor_only=True,dialogue_offer_verified=False,acceptance_observed=False)
    if not isinstance(offer,dict):return dict(result,missing=['current_offer.object'])
    giver=offer.get('giver_definition') or {}
    if not (offer.get('confirmed') is True and offer.get('available_candidate') is True
        and offer.get('eligible') is True and offer.get('eligibility_verified') is True
        and offer.get('source')=='current-root-typed-quest-collections'
        and giver.get('source')=='current_QuestStaticData.Sender'
        and giver.get('declared_type')=='Shared.Symbols.NPCSheetId'
        and type(giver.get('npc_id')) is int and giver['npc_id']!=0):
        return dict(result,missing=['verified_current_waiting_quest_and_typed_Sender'])
    try:
        if not -.5<=((now or datetime.now(timezone.utc))-_time(offer['observed_at'])).total_seconds()<=5:
            raise MovementEvidenceUnavailable('current_offer_stale')
        pin=movement_definition()['reader_rules']['map-memory-layout']
        if any((offer.get('source_version') or {}).get(k)!=pin[k] for k in ('game_assembly_sha256','metadata_sha256')):
            raise MovementEvidenceUnavailable('current_offer_client_version_mismatch')
        if current_snapshot is not None and [str(offer.get(k)) for k in ('process_started_at','character_id','session_id')]!=[str(x) for x in _owner(current_snapshot)[1:]]:
            raise MovementEvidenceUnavailable('current_offer_map_owner_mismatch')
        npc_id=giver['npc_id']&0xffffffff
        target={'kind':'npc','table_data':{'raw_id':npc_id},'identity_resolution':{'source_ref':{
            'source':giver['source'],'observed_at':offer['observed_at'],'quest_id':offer.get('quest_id'),
            'instance_id':offer.get('instance_id'),'source_version':deepcopy(offer['source_version'])}}}
        bound=_bind_static_movement_goal(target,current_snapshot)
        return dict(bound,dialogue_offer_verified=False,acceptance_observed=False,
                    current_offer={'quest_id':offer.get('quest_id'),'instance_id':offer.get('instance_id')},
                    preferred_current_native_path='plan_offer_autoplay')
    except (MovementEvidenceUnavailable,KeyError,TypeError,ValueError) as exc:
        return dict(result,missing=[str(exc)])


def _bind_static_movement_goal(target,current_snapshot=None):
    """Normalize one catalog objective token into an exact movement descriptor.

    ``ready`` means a descriptor can be planned, not that a target is currently
    selectable, reachable, or that the quest predicate has been reconstructed.
    Ordinary map markers use kind=marker; they are never renamed facilities.
    World-only and moongate tokens lacking typed joins remain explicit gaps.
    Items/fakeitems/equipment/gatherable tokens belong to acquisition handlers.
    """
    result={'ready':False,'goal':None,'source_refs':[],'missing':[],
            'descriptor_only':True,'live_ready':False,'static_world_projection':False,
            'runtime_target_predicate_verified':False,'applicable':False}
    if not isinstance(target,dict):
        return dict(result,missing=['target.object'])
    kind=target.get('kind')
    result['target_kind']=kind
    result['source_refs']=_target_sources(target)
    if kind not in ('npc','map','moongate','world','facility','dungeon_entrance'):
        return dict(result,missing=['acquisition_or_objective_handler_required:'+str(kind)])
    result['applicable']=True
    catalog=_static_catalog()
    definition=movement_definition()
    source_ref={'asset':definition['movement_catalog_ref']['path'],
                'sha256':definition['movement_catalog_ref']['sha256'],'source_kind':'bundled-static-catalog'}
    result['source_refs'].append(source_ref)
    if kind=='npc':
        npc_id=(target.get('table_data') or {}).get('raw_id')
        if type(npc_id) is not int or npc_id<=0:
            return dict(result,missing=['table_data.raw_id:verified NPC identity'])
        positions=[r for r in target.get('static_location_candidates',[]) if r.get('npc_raw_id',npc_id)==npc_id and r.get('world_data_key')]
        rows=[r for r in catalog['destinations'] if r.get('npc_id')==npc_id]
        worlds={r['world_data_key'] for r in positions+rows}
        observed_world=_current_exact_npc_world(current_snapshot,npc_id)
        if len(worlds)!=1:
            if observed_world and (not worlds or observed_world in worlds):
                world=observed_world
                result['source_refs'].append({'source_kind':'current-map-exact-npc-marker',
                     'source':current_snapshot['observation_source'],'captured_at':current_snapshot['captured_at']})
            else:
                return dict(result,missing=['unique static_location_candidates.world_data_key or fresh current exact NPC marker'],
                            candidate_worlds=sorted(worlds))
        else:
            world=next(iter(worlds))
        goal={'kind':'npc','npc_id':npc_id,'world_data_key':world,
              'title':(target.get('table_data') or {}).get('display_name') or target.get('raw')}
        result['source_refs'].extend({'asset':r.get('source_asset'), 'source_kind':'static-npc-deployment'} for r in positions if r.get('source_asset'))
    elif kind=='map':
        marker=target.get('marker_data') or {}
        args=target.get('arguments') or []
        marker_id=marker.get('id')
        world=marker.get('world_data_key')
        if not marker_id or not world:
            if len(args)>=2:
                matches=[r for r in catalog['destinations'] if r.get('symbol')==args[1] and r['world_data_key']==args[0]]
                if len(matches)==1:
                    marker_id,world=matches[0]['symbol'],matches[0]['world_data_key']
            if not marker_id or not world:
                return dict(result,missing=['marker_data.id','marker_data.world_data_key','exact static marker join'])
        if len(args)>=2 and (args[0]!=world or args[1]!=marker_id):
            return dict(result,missing=['map token and marker_data identity agreement'])
        if marker and not marker.get('source_asset'):
            return dict(result,missing=['marker_data.source_asset'])
        goal={'kind':'marker','marker_id':marker_id,'world_data_key':world,
              'title':marker.get('displayName') or marker_id}
        if marker.get('source_asset'):
            result['source_refs'].append({'asset':marker['source_asset'],'source_kind':'static-map-marker','marker_id':marker_id})
    elif kind=='moongate':
        args=target.get('arguments') or []
        definitions=definition.get('moon_gate_definitions',[])
        rows=[r for r in definitions if args and r['symbol']==args[0]]
        if len(rows)!=1:
            return dict(result,missing=['MoonGate typed/static definition','MoonGate→world_data_key',
                                        'MoonGate→current LocationDetailEventBuilder.moonGateSheetId join'])
        data=rows[0]
        goal={'kind':'marker','moon_gate_id':data['id'],'world_data_key':data['world_data_key'],'title':data['name']}
        result['source_refs'].append(deepcopy(data['source_ref']))
    elif kind=='world':
        return dict(result,missing=['specific destination/arrival predicate within region',
                                    'world-map region selection is not character movement'])
    else:
        data=target.get('movement_binding') or {}
        if not data.get('source_ref'):
            return dict(result,missing=['typed facility/dungeon identity binding source_ref'])
        try:
            goal=_normalise_goal(dict(data,kind=kind))
        except MovementEvidenceUnavailable as exc:
            return dict(result,missing=[str(exc)])
        result['source_refs'].append(deepcopy(data['source_ref']))
    goal['region_names']=sorted({r['name'] for r in catalog['game_spaces'] if r['world_data_key']==goal['world_data_key']})
    try:
        goal=_normalise_goal(goal)
    except MovementEvidenceUnavailable as exc:
        return dict(result,missing=[str(exc)])
    return dict(result,ready=True,goal=goal,
                required_runtime_fields=['current map marker/selected target identity','current pose/viewport/canvas',
                                         'current plain move CTA','current character channel/position'])


def _number(value):
    if type(value) not in (int, float) or not math.isfinite(value):
        raise MovementEvidenceUnavailable('non_finite_coordinate')
    return float(value)


def _pair(value):
    if not isinstance(value, (list, tuple)) or len(value) != 2:
        raise MovementEvidenceUnavailable('coordinate_pair_missing')
    return tuple(_number(v) for v in value)


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


def _owner(snapshot):
    result = [snapshot['pid'], snapshot['process_started_at'],
              snapshot['identity']['character_id'], snapshot['session_id']]
    if any(value is None or value == '' for value in result):
        raise MovementEvidenceUnavailable('current_owner_missing')
    return result


def _time(value):
    parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
    if parsed.tzinfo is None:
        raise MovementEvidenceUnavailable('observation_timezone_missing')
    return parsed


def _base(snapshot, goal, receipt, now):
    if not snapshot.get('observation_source') or snapshot.get('certainty') != 'confirmed':
        raise MovementEvidenceUnavailable('current_map_observation_unconfirmed')
    captured = _time(snapshot['captured_at'])
    now = now or datetime.now(timezone.utc)
    age = (now - captured).total_seconds()
    if age < -.5 or age > 5:
        raise MovementEvidenceUnavailable('current_map_observation_stale')
    owner = _owner(snapshot)
    keys=('kind','world_data_key','npc_id','marker_id','dungeon_id','battlefield_id','moon_gate_id','map_position','coordinate_space')
    goal_key = _digest({k:goal[k] for k in keys if k in goal})
    if receipt and (receipt.get('owner') != owner or receipt.get('goal_key') != goal_key):
        raise MovementEvidenceUnavailable('pending_movement_owner_or_goal_changed')
    return {'owner': owner, 'goal_key': goal_key, 'captured_at': snapshot['captured_at']}


def _wait(reason, evidence=None, receipt=None):
    return {'status': 'waiting', 'action': None, 'wait_reason': reason,
            'evidence': evidence or {}, 'next_receipt': deepcopy(receipt),
            'sent_to_game': False, 'game_tested': False}


def _action(kind, role, base, evidence, *, phase, prior=None, **payload):
    receipt = dict(base, phase=phase)
    if prior:
        receipt.update(prior)
    return {'status': 'action', 'action': dict(kind=kind, role=role,
                receipt_phase=phase, **payload), 'wait_reason': None,
            'next_receipt': receipt, 'evidence': evidence,
            'sent_to_game': False, 'game_tested': False}


def _pose(snapshot):
    raw = snapshot['pose']
    width, height = _pair(snapshot['viewport'])
    ui_w, ui_h = _pair(raw['layout_size_ui'])
    if min(width, height, ui_w, ui_h) <= 0:
        raise MovementEvidenceUnavailable('map_viewport_unavailable')
    scale = width / ui_w
    if not math.isclose(scale, height / ui_h, rel_tol=.002, abs_tol=.002):
        raise MovementEvidenceUnavailable('map_viewport_scale_mismatch')
    zoom = _number(raw['zoom'])
    size = _number(raw['world_size'])
    if not -16 <= zoom <= 16 or size <= 0 or not raw.get('resource'):
        raise MovementEvidenceUnavailable('map_pose_invalid')
    return {'centre': _pair(raw['centre']), 'factor': 2 ** zoom * scale,
            'scale': scale, 'width': width, 'height': height, 'world_size': size,
            'signature': _digest(raw)}


def _point(pose, map_position):
    x, y = _pair(map_position)
    if abs(x) > pose['world_size']/2 or abs(y) > pose['world_size']/2:
        raise MovementEvidenceUnavailable('marker_outside_map_extent')
    cx, cy = pose['centre']
    return [pose['width']/2+(x-cx)*pose['factor'],
            pose['height']/2-(y-cy)*pose['factor']]


def _touch(snapshot, pose):
    safe = Rect(*snapshot['safe_rect'])
    viewport = Rect(0, 0, pose['width'], pose['height'])
    if not viewport.contains(safe):
        raise MovementEvidenceUnavailable('safe_rect_outside_viewport')
    s = pose['scale']
    top, right, bottom = safe.top + 100*s, safe.right - 80*s, safe.bottom - 20*s
    if snapshot.get('selected'):
        if pose['width'] >= pose['height']:
            right = min(right, safe.right - (720+32+16)*s)
        elif snapshot.get('panel'):
            panel = snapshot['panel']
            bottom = min(bottom, panel['rect_ui'][1]*panel['scale']-16*s)
        else:
            raise MovementEvidenceUnavailable('portrait_selected_panel_missing')
    return Rect(safe.left+20*s, top, right-safe.left-20*s, bottom-top)


def _control(snapshot, role):
    point = _pair(snapshot.get('controls', {}).get(role))
    width, height = _pair(snapshot['viewport'])
    if not 0 < point[0] < width or not 0 < point[1] < height:
        raise MovementEvidenceUnavailable('prepared_control_outside_viewport:' + role)
    return list(point)


def _matched(marker, goal):
    if not isinstance(marker,dict):
        return False
    detail=marker.get('detail') or {}
    for key in ('npc_id','dungeon_id','battlefield_id','moon_gate_id'):
        if goal.get(key) is not None and detail.get(key)!=goal[key]:
            return False
    if goal.get('marker_id'):
        actual=detail.get('marker_id') or ''
        if actual!=goal['marker_id'] and not actual.endswith('/'+goal['marker_id']):
            return False
    return goal['kind']!='map_position'


def _arrived(snapshot, target):
    # The target must originate from the CURRENT selected location detail,
    # not the static NPC deployment. Missing channels must never compare equal.
    channel = target.get('target_channel')
    if type(channel) is not int or snapshot.get('current_channel') != channel:
        return False
    accuracy = _number(snapshot.get('position_accuracy_world'))
    if not 0 <= accuracy <= 1:
        return False
    return math.dist(_pair(snapshot.get('world_position')),
                     _pair(target.get('world_position'))) <= 2


def plan_npc_movement(snapshot, goal, receipt=None, *, now=None):
    """Compatibility entry point; NPC behavior now uses the common planner."""
    return plan_movement(snapshot,goal,receipt,now=now)


def plan_movement(snapshot, goal, receipt=None, *, now=None):
    """Common NPC/map-position/facility/dungeon-entrance movement planner."""
    try:
        return _plan_movement(snapshot, _normalise_goal(goal), receipt, now)
    except (MovementEvidenceUnavailable, KeyError, ValueError, TypeError, OverflowError) as exc:
        return _wait('movement_evidence_unavailable:' + str(exc),
                     {'required': 'current map marker/pose/panel and character owner'}, receipt)


def _plan_movement(snapshot, goal, receipt, now):
    base = _base(snapshot, goal, receipt, now)
    evidence = {'observation_source': snapshot['observation_source'], **base,
                'coordinate_space': 'physical-client-pixels', 'static_world_projection': False}
    phase = (receipt or {}).get('phase')
    if phase == 'move_sent':
        if _arrived(snapshot, receipt['target']):
            return {'status': 'arrived', 'action': None, 'wait_reason': None,
                    'evidence': dict(evidence, target=receipt['target'], arrival_radius_world=2,
                                     position_accuracy_world=snapshot['position_accuracy_world']),
                    'next_receipt': dict(receipt, phase='arrived'), 'sent_to_game': False,
                    'game_tested': False}
        return _wait('movement_in_flight_or_effect_unconfirmed_no_resend', evidence, receipt)
    if not snapshot.get('map_visible'):
        if phase:
            return _wait('map_closed_or_open_effect_unconfirmed_no_resend', evidence, receipt)
        return _action('click', 'map.open', base, evidence, phase='open_sent',
                       point=_control(snapshot, 'map.open'))
    if snapshot.get('map_ready') is not True:
        return _wait('map_transition_or_data_not_ready', evidence, receipt)
    pose = _pose(snapshot)
    evidence['pose_signature'] = pose['signature']
    evidence['projection'] = '(currentMapPosition-currentCentre)*2^currentZoom*currentCanvasScale; Y up'
    if phase in ('pan_sent', 'zoom_sent') and receipt.get('pose_signature') == pose['signature']:
        return _wait('map_pan_or_zoom_effect_unconfirmed_no_repeat', evidence, receipt)
    local_map = snapshot['pose']['map_type'] != 1
    correct_map = local_map and snapshot.get('world_data_key') == goal['world_data_key']
    if not correct_map and local_map:
        if phase in ('world_sent', 'region_sent', 'select_sent'):
            return _wait('region_transition_unconfirmed_no_resend', evidence, receipt)
        return _action('click', 'map.world', base, evidence, phase='world_sent',
                       point=_control(snapshot, 'map.world'))
    region = not local_map
    if region and phase == 'region_sent':
        return _wait('region_selection_effect_unconfirmed_no_resend', evidence, receipt)
    selected = snapshot.get('selected')
    coordinate_goal=goal['kind']=='map_position'
    if coordinate_goal:
        selected_matches=bool(selected and selected.get('detail') and selected.get('long_press') is True and
                              math.dist(_pair(selected.get('map_position')),_pair(goal['map_position']))<=2/pose['factor'])
    else:
        selected_matches=_matched(selected,goal)
    if correct_map and selected_matches:
        detail = selected['detail']
        target = {'world_position': detail.get('world_position'),
                  'target_channel': detail.get('target_channel')}
        _pair(target['world_position'])
        if type(target['target_channel']) is not int:
            return _wait('selected_target_channel_unavailable', evidence, receipt)
        if _arrived(snapshot, target):
            return {'status': 'arrived', 'action': None, 'wait_reason': None,
                    'evidence': dict(evidence, target=target, arrival_radius_world=2),
                    'next_receipt': dict(base, phase='arrived', target=target),
                    'sent_to_game': False, 'game_tested': False}
        if not all(detail.get(k) is True for k in ('move_enabled', 'auto_move_enabled', 'move_event_approved')) or detail.get('label'):
            return _wait('selected_move_not_plain_enabled_auto_walk', evidence, receipt)
        panel = snapshot.get('panel')
        if not panel:
            return _wait('selected_move_panel_layout_missing', evidence, receipt)
        scale = _number(panel['scale'])
        sw, sh = _pair(panel['screen_ui'])
        if scale <= 0 or abs(sw*scale-pose['width']) > 2 or abs(sh*scale-pose['height']) > 2:
            return _wait('selected_move_panel_scale_mismatch', evidence, receipt)
        rect = Rect(*(v*scale for v in panel['rect_ui']))
        if not Rect(0, 0, pose['width'], pose['height']).contains(rect):
            return _wait('selected_move_panel_clipped', evidence, receipt)
        if abs(rect.bottom-(pose['height']-32*scale)) > 2 or abs(rect.height-80*scale) > 2:
            return _wait('selected_move_panel_animation_or_scroll_unsettled', evidence, receipt)
        return _action('click', 'map.move', base, dict(evidence, panel_source=panel.get('source'), target=target),
                       phase='move_sent', prior={'target':target}, point=list(rect.center))
    if phase == 'select_sent':
        return _wait('target_selection_effect_unconfirmed_no_resend', evidence, receipt)
    names = set(goal.get('region_names', []))
    if coordinate_goal and correct_map:
        marker={'map_position':goal['map_position'],'key':'explicit-current-map-coordinate','selected':True}
    else:
        choices = [m for m in snapshot['markers'] if
                   (((m.get('detail') or {}).get('location_type') == 3 and
                     (m.get('detail') or {}).get('title') in names) if region else _matched(m, goal))]
        if len(choices) != 1:
            return _wait('region_marker_missing_or_ambiguous' if region else 'target_marker_missing_locked_or_ambiguous',
                         dict(evidence, matching_markers=len(choices)), receipt)
        marker = choices[0]
    point = _point(pose, marker['map_position'])
    area = _touch(snapshot, pose)
    evidence.update(marker_key=marker.get('key'), marker_map_position=marker['map_position'],
                    point=point, safe_touch_rect=[area.left, area.top, area.width, area.height])
    low, high, ratio = marker.get('ratio_min'), marker.get('ratio_max'), snapshot.get('marker_zoom_ratio')
    if not marker.get('selected'):
        if None in (low, high, ratio):
            return _wait('marker_current_visibility_ratio_missing', evidence, receipt)
        low, high, ratio = _number(low), _number(high), _number(ratio)
        if not low <= ratio <= high:
            return _action('scroll', 'map.zoom', base, evidence, phase='zoom_sent',
                           prior={'pose_signature':pose['signature']}, point=list(area.center), delta=120 if ratio < low else -120)
    if not area.contains(Rect(point[0]-2, point[1]-2, 4, 4)):
        anchor = area.center
        dx, dy = anchor[0]-point[0], anchor[1]-point[1]
        divisor = max(1, abs(dx)/(area.width*.7), abs(dy)/(area.height*.7))
        dx, dy = dx/divisor, dy/divisor
        start, end = [anchor[0]-dx/2, anchor[1]-dy/2], [anchor[0]+dx/2, anchor[1]+dy/2]
        if math.dist(start, end) <= 12:
            return _wait('insufficient_pan_space', evidence, receipt)
        return _action('map_drag', 'map.pan', base, evidence, phase='pan_sent',
                       prior={'pose_signature':pose['signature']}, start=start, end=end)
    if coordinate_goal and correct_map:
        if snapshot.get('hold_threshold') is None:
            return _wait('current_map_long_press_threshold_missing:'+str(snapshot.get('hold_threshold_missing','not supplied')),evidence,receipt)
        threshold=_number(snapshot.get('hold_threshold'))
        if not 0<threshold<=10:
            return _wait('current_map_long_press_threshold_invalid',evidence,receipt)
        return _action('map_long_press','map.target',base,evidence,phase='select_sent',
                       point=point,hold_seconds=threshold+.15)
    return _action('click', 'map.region' if region else 'map.target', base, evidence,
                   phase='region_sent' if region else 'select_sent', point=point)


def prepare_egg_gather_tape(width, height, *, ui_percent=70):
    """Compatibility wrapper for the daily-001 authored tape."""
    return prepare_gather_tape('daily-001',width,height,ui_percent=ui_percent)


def prepare_gather_tape(node_id, width, height, *, ui_percent=70, safe_rect=None):
    """Prepare selection from the shared source layout at the current canvas.

    Initial HUD/selection ownership remains the caller's responsibility. Current
    requirements and existing quest-state observations remain separate from geometry.
    """
    from mini_masukja import catalog
    from mini_masukja.ui_layout import Viewport,resolve_canvas,Rect as SourceRect
    from mini_masukja.ui_rule_pack import load_rule_pack
    from mini_masukja.source_life_routes import resolve_life_scene
    if type(width) is not int or type(height) is not int or min(width,height)<=0:
        raise MovementEvidenceUnavailable('gather_selection_viewport_invalid')
    if not movement_definition()['gather_routes'].get(node_id):
        raise MovementEvidenceUnavailable('gather_node_not_in_authored_catalog:'+str(node_id))
    groups=catalog.gather_catalog()['groups']
    matches=[(g,n) for g in groups for n in g['nodes'] if n['id']==node_id]
    if len(matches)!=1:raise MovementEvidenceUnavailable('gather_source_catalog_identity_ambiguous')
    group,node=matches[0];orientation='landscape' if width>height else 'portrait'
    context=resolve_canvas(Viewport(width,height),'pc',ui_percent,orientation=orientation,
        profile=load_rule_pack().profile_for(orientation))
    safe=Rect(*(safe_rect or [0,0,width,height]))
    area=SourceRect(safe.left,safe.top,safe.width,safe.height)
    actions=[dict(kind='key',key='C',delay_before_seconds=0)]
    def scene(name,state):
        return resolve_life_scene(context,name,state=dict(state,safe_area=area))
    def click(role,row):
        if not row.visible or row.visible_rect is None:
            raise MovementEvidenceUnavailable('gather_source_target_outside_viewport:'+role)
        actions.append(dict(kind='click',role=role,point=list(row.rect.center),delay_before_seconds=.5))
    click('gather.tab.LivingSkill',scene('gather.tabs',dict(ordered_ids=('State','CombatSkill','LivingSkill','Class','MemorizeCollectionRune')))['gather.tab.LivingSkill'])
    role='gather.category.'+group['id']
    click(role,scene('gather.categories',dict(ordered_ids=[g['id'] for g in groups],scroll_x=0,scroll_y=0))[role])
    state=dict(ordered_ids=[n['id'] for n in group['nodes']],category_id=group['id'],scroll_x=0,scroll_y=0,focus_id=node_id)
    while True:
        rows=scene('gather.items',state);scroll=rows['gather.list.scroll'];delta=scroll.metadata['next_wheel_delta']
        if not delta:break
        actions.append(dict(kind='scroll',role='gather.list.scroll',point=list(scroll.rect.center),delta=delta,delay_before_seconds=.2))
        next_y=scroll.metadata['next_scroll_y']
        if not state['scroll_y'] < next_y <= scroll.metadata['max_scroll_y']:
            raise MovementEvidenceUnavailable('gather_source_scroll_plan_not_progressing')
        state['scroll_y']=next_y
    click('gather.item.'+node_id,rows['gather.item.'+node_id])
    detail=scene('gather.travel',dict(node_id=node_id,detail_confirmed=True,
        requirement_met=True,link_confirmed=True,settled=True,scroll_y=0,
        link_kind='fishing' if node_id.startswith('fishing-') else 'nearby'))
    travel=detail['gather.travel'];requirement=detail['gather.detail.requirements']
    # Serialize prepared physical rectangles; the consumer does no UI query.
    exclusions=[[x1*width,y1*height,x2*width,y2*height]
        for x1,y1,x2,y2 in requirement.metadata.get('normalized_exclusions',())]
    detail_geometry=dict(node_id=node_id,viewport=[width,height],
        frame=list(travel.metadata['parent_rect']),requirements=list(requirement.ocr_rect.bounds),
        exclusions=exclusions,link_rect=list(travel.rect.bounds),
        source=travel.metadata['geometry_kind'],parent_kind=travel.metadata['parent_basis'])
    return dict(actions=actions,node_id=node_id,detail_geometry=detail_geometry,
        source_status='shared_source_layout_selection_candidate',
        game_tested=False,sends_game_input=False)


def egg_nearby_candidate(*, width, height, ui_percent, canvas_scale, safe_rect, detail):
    """Compatibility wrapper for the daily-001 selected-detail candidate."""
    return gather_nearby_candidate('daily-001',width=width,height=height,ui_percent=ui_percent,
                                  canvas_scale=canvas_scale,safe_rect=safe_rect,detail=detail)


def gather_nearby_candidate(node_id, *, width, height, ui_percent, canvas_scale, safe_rect, detail):
    """Consume the link prepared with the selection tape.

    Existing selection ownership and requirement checks do not reacquire geometry.
    """
    if not movement_definition()['gather_routes'].get(node_id):
        raise MovementEvidenceUnavailable('gather_node_not_in_authored_catalog:'+str(node_id))
    if min(width,height)<=0:raise MovementEvidenceUnavailable('current_viewport_invalid')
    link_kind='fishing' if node_id.startswith('fishing-') else 'nearby'
    if detail.get('node_id') != node_id or detail.get('link_kind') != link_kind or not all(
            detail.get(k) is True for k in ('owned', 'requirement_met', 'link_confirmed', 'settled')):
        raise MovementEvidenceUnavailable('current_egg_detail_or_requirements_unconfirmed')
    scale = _number(canvas_scale)
    safe = Rect(*safe_rect)
    if scale <= 0 or not Rect(0, 0, width, height).contains(safe):
        raise MovementEvidenceUnavailable('current_canvas_or_safe_rect_missing')
    bounds=detail.get('prepared_link_rect')
    if not isinstance(bounds,(list,tuple)) or len(bounds)!=4:
        raise MovementEvidenceUnavailable('prepared_gather_link_required')
    l,top,r,b=map(_number,bounds)
    rect=Rect(l,top,r-l,b-top)
    if not safe.contains(rect):raise MovementEvidenceUnavailable('prepared_gather_link_outside_safe_area')
    return {'kind':'click','role':'gather.travel.'+node_id,'point':list(rect.center),
            'source_rect':list(bounds),'evidence':{'current_detail':deepcopy(detail),
                'geometry_source':'prepared common gather.travel layout'},
            'game_tested':False,'sent_to_game':False}
