"""Map-card navigation/native autoplay; direct accept is a distinct gap.

No button action is reported as quest acceptance. The only accepted result is
a fresh typed ActiveQuest proof for the exact waiting instance we owned.
"""
from copy import deepcopy
from datetime import datetime, timezone

from .map_quest_accept_observer import SOURCE,source_definition,source_hash
from .quest_ui_actions import prepare_pointer_proposal
from .quest_autoplay import plan_offer_autoplay
from .coordinate_geometry import CLIENT_XYWH, safe_rect_xywh_from_frame

OWNER=('quest_id','instance_id','session_id','character_id','process_started_at')


def _fresh(row,now):
    try:
        stamp=datetime.fromisoformat(row['observed_at'].replace('Z','+00:00'))
        return stamp.tzinfo is not None and -.5<=(now-stamp).total_seconds()<=5
    except (KeyError,ValueError,TypeError,AttributeError):return False


def _wait(reason,receipt=None,**evidence):
    owned_pending=bool(receipt) and reason in {
        'map_card_owned_layout_update_pending','map_card_owned_probe_cache_pending',
        'map_card_strict_later_successor_required','map_card_unfold_effect_unconfirmed_no_resend',
        'map_card_scroll_effect_unconfirmed_no_resend','map_card_scroll_model_changed_pending_preserved',
        'native_map_card_autoplay_sent_accept_effect_unconfirmed_no_resend',
        'map_quest_card_open_effect_unconfirmed_no_resend','map_arbeit_category_effect_unconfirmed_no_resend'}
    return dict(wait_kind='owned_input_effect' if owned_pending else None,
                wait_family='map_quest_card' if owned_pending else None,status='waiting',action=None,wait_reason=reason,next_receipt=deepcopy(receipt),
                evidence=dict(action_scope='quest_instance_native_autoplay',direct_accept_supported=False,**evidence),
                sent_to_game=False,game_tested=False)


def _later(current,previous):
    try:return datetime.fromisoformat(current.replace('Z','+00:00'))>datetime.fromisoformat(previous.replace('Z','+00:00'))
    except (TypeError,ValueError,AttributeError):return False


def _scroll_moved(before,after,target_index,delta):
    """A digest change alone is not an input effect (e.g. changed card text)."""
    if (before.get('viewport_rect') is None or before.get('viewport_rect')!=after.get('viewport_rect') or
            before.get('orientation')!=after.get('orientation')):return False
    axis=before.get('scroll_axis');later_axis=after.get('scroll_axis')
    if axis!=later_axis:return False
    coordinate=axis.get('axis_index') if axis else 1
    sign=axis.get('wheel_to_screen_sign') if axis else 1
    if coordinate not in (0,1) or sign not in (-1,1):return False
    physical_delta=delta*sign if delta in (-120,120) else 0
    old=before.get('visible_rows') or [];new=after.get('visible_rows') or []
    if not old or not new or type(target_index) is not int or delta not in (-120,120):return False
    cached=(before.get('layout_source')=='current_collection_layout_output_cache' and
        before.get('layout_source_revision')==source_hash() and after.get('layout_source_revision')==source_hash()
        and before.get('layout_signature') and before.get('layout_signature')==after.get('layout_signature'))
    if cached:
        targets=before.get('layout_centers') or []
        if not 0<=target_index<len(targets):return False
        key='layout_center_axis' if axis else 'layout_center_y'
        old_y=[r.get(key) for r in old];new_y=[r.get(key) for r in new]
        if any(type(y) not in (int,float) for y in old_y+new_y):return False
        if physical_delta<0 and targets[target_index]>max(old_y) and min(new_y)>min(old_y) and max(new_y)>max(old_y):return True
        if physical_delta>0 and targets[target_index]<min(old_y) and min(new_y)<min(old_y) and max(new_y)<max(old_y):return True
    old_ids=[r['index'] for r in old];new_ids=[r['index'] for r in new]
    if not cached and target_index>max(old_ids) and min(new_ids)>min(old_ids) and max(new_ids)>max(old_ids):return True
    if not cached and target_index<min(old_ids) and min(new_ids)<min(old_ids) and max(new_ids)<max(old_ids):return True
    shared=[(a,b) for a in old for b in new if a['index']==b['index'] and a.get('instance_id')==b.get('instance_id')]
    if len(shared)<(1 if cached else 2):return False
    shifts=[]
    for a,b in shared:
        x=a.get('rect_client');y=b.get('rect_client')
        if not x or not y or x[1-coordinate]!=y[1-coordinate] or x[2:]!=y[2:]:return False
        shifts.append(y[coordinate]-x[coordinate])
    return all(v*physical_delta>0 and abs(v-shifts[0])<.5 for v in shifts)


def pointer_semantics(candidate,scope,stage):
    value=dict(action_kind='pointer_scroll' if stage=='scroll' else 'pointer_click',map_quest_card_scope=scope,stage=stage)
    if stage=='scroll':value.update(delta=candidate['delta'],model_index=candidate['model_index'],
        scroll_observation=candidate['scroll_observation'],registration=candidate['registration'])
    return value


def _scope(row):return {key:deepcopy(row[key]) for key in OWNER}


def _owned(row,scope):
    return all(row.get(k) is not None and str(row[k])==str(scope[k]) for k in OWNER)


def _pointer(candidate,current,scope,base,frame,stage,receipt=None):
    typed=deepcopy(current)
    typed.update(target_id=candidate['target_id'],ui_type='MapQuestPanelUI')
    typed['pointer_semantics']=pointer_semantics(candidate,scope,stage)
    if (receipt or {}).get('stage') in ('open_sent','category_sent','unfold_sent','scroll_sent'):
        frame=deepcopy(frame)
        frame.pop('pointer_probe_receipt',None);frame.pop('pending_input',None)
    if (receipt or {}).get('stage')==stage+'_probe_sent':
        previous=(receipt.get('pointer_binding') or {}).get('probe_context') or {}
        pending=[p for p in current.get('pointer_progress',[]) if p.get('kind')=='pointer_probe_cache_pending'
            and p.get('stage')==stage and p.get('offer_scope')==scope
            and p.get('scene_signature')==previous.get('scene_signature')
            and p.get('input_epoch')==previous.get('input_epoch')]
        if len(pending)==1:return _wait('map_card_owned_probe_cache_pending',receipt,transient=pending[0])
    bound=prepare_pointer_proposal(candidate,typed,frame)
    left,top,width,height=candidate['rect_client']
    physical=frame['physical_client_rect']
    safe=safe_rect_xywh_from_frame(frame, full_client_fallback=True)
    bound['probe_context'].update(source_guard_kind='map_quest_card',map_quest_card_scope=scope)
    kind=bound['input_kind'];phase=stage+'_probe_sent' if kind=='pointer_probe' else stage+'_sent'
    base=dict(base,observed_at=current['observed_at'],pid=current['pid'])
    return dict(status='action',action=dict(kind=kind,role=candidate['target_id'],point=bound['point_client'],delta=bound.get('delta'),
        probe_context=bound['probe_context'],pointer_binding=deepcopy(bound),receipt_phase=phase,
        source_rect=[left,top,left+width,top+height],safe_rect=deepcopy(safe),safe_rect_format=CLIENT_XYWH,
        input_epoch=bound['probe_context']['input_epoch'],pointer_verified=deepcopy(bound.get('pointer_verified'))),
        next_receipt=dict(base,stage=phase,pointer_binding=deepcopy(bound),card_index=candidate['index'],
            scroll_observation=deepcopy(candidate.get('scroll_observation')),scroll_delta=candidate.get('delta'),
            target_model_index=candidate.get('model_index')),wait_reason=None,
        evidence=dict(source=SOURCE,action_scope='quest_instance_native_autoplay',direct_accept_supported=False,
            instance_id=scope['instance_id'],quest_id=scope['quest_id'],callback=candidate.get('callback')),
        sent_to_game=False,game_tested=False)


def plan_map_quest_accept(node,snapshot,frame,receipt=None,*,now=None):
    """Return movement-style action/receipt; card action is *not* acceptance.

    snapshot includes offers/state_proofs/map/current_map_accept. Persist the
    returned receipt before dispatch. Unknown native/direct receipts are never
    converted into this flow. Root must select this alternative before input.
    """
    now=now or datetime.now(timezone.utc)
    try:return _plan(node,snapshot,frame,receipt,now)
    except (ValueError,KeyError,TypeError,AttributeError) as exc:
        return _wait('map_quest_card_evidence_unavailable:'+str(exc),receipt)


def _plan(node,snapshot,frame,receipt,now):
    if node.get('op')!='dialogue.accept':return _wait('map_quest_card_only_pre_accept_candidate',receipt)
    qid=node.get('params',{}).get('quest_id',node.get('quest_id'))
    if type(frame.get('pid')) is not int or frame['pid']<=0:return _wait('current_map_card_pid_required',receipt)
    if receipt and receipt.get('flow')!='map_quest_card.native_autoplay':
        return _wait('existing_unknown_native_or_direct_intent_preserved',receipt)
    if receipt:
        if receipt.get('pid') is not None and receipt['pid']!=frame.get('pid'):return _wait('map_quest_card_receipt_pid_changed',receipt)
        if receipt.get('source_revision')!=source_hash():return _wait('map_quest_card_receipt_source_changed',receipt)
        scope=receipt['offer_scope']
        if str(scope['quest_id'])!=str(qid):return _wait('map_quest_card_receipt_quest_changed',receipt)
        for key in OWNER[2:]:
            if str(snapshot.get(key))!=str(scope[key]):return _wait('map_quest_card_owner_changed',receipt)
        for proof in snapshot.get('state_proofs',[]):
            if (proof.get('state')=='accepted' and proof.get('confirmed') is True and _owned(proof,scope)
                    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('accept_time') is not None and _fresh(proof,now)
                    and all((proof.get('source_version') or {}).get(k)==source_definition()[k]
                            for k in ('metadata_sha256','game_assembly_sha256'))
                    and _later(proof['observed_at'],receipt['observed_at'])):
                return dict(status='accepted',action=None,wait_reason=None,next_receipt=deepcopy(receipt),
                            evidence=deepcopy(proof),sent_to_game=False,game_tested=False)
        if receipt.get('stage')=='autoplay_sent':
            return _wait('native_map_card_autoplay_sent_accept_effect_unconfirmed_no_resend',receipt)
    rows=[o for o in snapshot.get('offers',[]) if str(o.get('quest_id'))==str(qid)]
    if len(rows)!=1:return _wait('current_unique_waiting_quest_instance_required',receipt)
    offer=rows[0]
    if (offer.get('confirmed') is not True or offer.get('eligible') is not True or
        offer.get('eligibility_verified') is not True or offer.get('conditions_met') is not True or
        offer.get('source')!='current-root-typed-quest-collections' or
        offer.get('basis')!='verified_current_waiting_ActiveQuest_status_and_current_QuestStaticData_Sender' or
        type(offer.get('instance_id')) is not int or offer['instance_id']<=0 or not _fresh(offer,now)):
        return _wait('verified_fresh_waiting_quest_instance_required',receipt)
    scope=_scope(offer)
    if any(str(snapshot.get(k))!=str(scope[k]) or str(frame.get(k))!=str(scope[k]) for k in OWNER[2:]):return _wait('map_quest_card_offer_owner_changed',receipt)
    if receipt and scope!=receipt['offer_scope']:return _wait('map_quest_card_waiting_instance_replaced',receipt)
    base=dict(flow='map_quest_card.native_autoplay',offer_scope=scope,source_revision=source_hash(),
              observed_at=offer['observed_at'],pid=frame.get('pid'),direct_accept_supported=False)
    map_state=snapshot.get('map') or {}
    if map_state.get('map_visible') is False:
        if map_state.get('pid')!=frame.get('pid'):return _wait('map_quest_card_map_pid_changed',receipt)
        base['observed_at']=map_state.get('captured_at')
        if receipt:return _wait('map_quest_card_open_effect_unconfirmed_no_resend',receipt)
        planned=plan_offer_autoplay(map_state,offer,now=now)
        if planned.get('status')=='action' and planned['action'].get('role')=='map.open':
            planned=deepcopy(planned);planned['next_receipt']=dict(base,stage='open_sent',map_receipt=planned['next_receipt'])
            planned['evidence']['direct_accept_supported']=False
            return planned
        return _wait('current_source_map_open_unavailable',receipt,detail=planned)
    current=snapshot.get('current_map_accept') or {}
    if (current.get('confirmed') is not True or current.get('source')!=SOURCE or
        current.get('source_revision')!=source_hash() or not _fresh(current,now)):
        return _wait('current_source_map_quest_panel_required',receipt)
    if current.get('pid')!=frame.get('pid'):return _wait('current_map_card_pid_changed',receipt)
    if any(str(current.get(k))!=str(scope[k]) for k in OWNER[2:]):return _wait('current_map_card_owner_changed',receipt)
    if (current.get('frame_id')!=frame.get('frame_id') or current.get('window_signature')!=frame.get('window_signature') or
        current.get('input_epoch')!=(frame.get('input_owner') or {}).get('epoch')):
        return _wait('current_map_card_frame_or_input_epoch_changed',receipt)
    if any((offer.get('source_version') or {}).get(k)!=source_definition()[k] for k in ('metadata_sha256','game_assembly_sha256')):
        return _wait('current_offer_map_source_revision_changed',receipt)
    if not current.get('map_panel_visible'):return _wait('current_map_quest_panel_not_visible',receipt)
    previous=(receipt or {}).get('stage')
    if previous in ('open_sent','category_sent','unfold_sent','scroll_sent') and not _later(current['observed_at'],receipt['observed_at']):
        return _wait('map_card_strict_later_successor_required',receipt)
    if current.get('current_category')!=2:
        if receipt and receipt.get('stage')=='category_sent':
            return _wait('map_arbeit_category_effect_unconfirmed_no_resend',receipt)
        candidate=current.get('category_candidate')
        if not candidate:return _wait('current_map_arbeit_category_input_unavailable',receipt,detail=current.get('category_missing'))
        return _pointer(candidate,current,scope,base,frame,'category',receipt)
    cards=[c for c in current.get('cards',[]) if c.get('instance_id')==scope['instance_id']]
    if len(cards)!=1:return _wait('current_exact_waiting_instance_card_not_in_list',receipt)
    card=cards[0]
    transient=current.get('transient') or {}
    if (transient.get('kind')=='collection_layout_updating' and transient.get('source')=='current_collection_update_flags'
            and any((transient.get('flags') or {}).get(k) is True for k in ('needDataUpdate','needLayoutUpdate'))):
        if receipt and previous in ('open_sent','category_sent','unfold_sent','scroll_sent','category_probe_sent','unfold_probe_sent','scroll_probe_sent','autoplay_probe_sent'):
            return _wait('map_card_owned_layout_update_pending',receipt,transient=transient)
        return _wait('current_map_card_layout_update_pending',receipt,transient=transient)
    if card['arbeit_type']==1:return _wait('membership_purchase_card_is_not_allowed',receipt)
    if any(card.get(k) for k in ('accepted','time_over','cleared','auto_playing')):
        return _wait('current_card_state_requires_separate_quest_effect',receipt)
    if previous=='unfold_sent' and card.get('folded'):
        return _wait('map_card_unfold_effect_unconfirmed_no_resend',receipt)
    if previous=='scroll_sent' and not card.get('rendered'):
        old=receipt.get('scroll_observation') or {};new=current.get('scroll_observation') or {}
        if not old or new.get('model_revision')!=old.get('model_revision'):
            return _wait('map_card_scroll_model_changed_pending_preserved',receipt)
        if not _scroll_moved(old,new,receipt.get('target_model_index'),receipt.get('scroll_delta')):
            return _wait('map_card_scroll_effect_unconfirmed_no_resend',receipt)
    if not card.get('rendered'):
        candidates=[c for c in current.get('scroll_candidates',[]) if c.get('index')==card['index']]
        if len(candidates)!=1:return _wait('current_map_card_scroll_receiver_unavailable',receipt,detail=current.get('scroll_missing'))
        return _pointer(candidates[0],current,scope,base,frame,'scroll',receipt)
    if card.get('folded'):
        candidates=[c for c in current.get('unfold_candidates',[]) if c.get('index')==card['index']]
        if len(candidates)!=1:return _wait('current_card_unfold_registered_receiver_unavailable',receipt,detail=card.get('unfold_missing'))
        return _pointer(candidates[0],current,scope,base,frame,'unfold',receipt)
    if not card.get('input_ready'):return _wait('current_map_card_input_not_ready',receipt,detail=card.get('input_missing'))
    candidates=[c for c in current.get('candidates',[]) if c.get('index')==card['index']]
    if len(candidates)!=1:return _wait('current_unique_map_card_button_required',receipt)
    return _pointer(candidates[0],current,scope,base,frame,'autoplay',receipt)
