"""Source-pinned, read-only ownership of the game's current BackButton receiver.

No native functions are called. This reads the same registry and scalar inputs
as TryGetPrimaryHandler and supports only an exact owned popup close callback.
Importing this module neither opens a process nor sends an input.
"""
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import math
import struct

from .paths import root
from .vendor.map_character_context import _class
from .vendor.map_memory import MapReadPending
from .material_return import MMML_REVISION

TARGET_ID = 'material_return.close.detail'
SOURCE_REF = 'data/arbeit-material-return/escape-source.json'


@lru_cache(maxsize=1)
def escape_source():
    return json.loads((root()/SOURCE_REF).read_text(encoding='utf-8'))


def _signature(value):
    return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()


def select_primary_handler(rows):
    """Mirror source ordering, refusing an unobserved tag enumeration tie."""
    eligible = []
    for row in rows:
        if row.get('tag_comparison_verified') is True and row.get('tag_matches') is False:
            continue
        if row.get('back_tag_verified') is not True:
            raise MapReadPending('current BackButton tag comparison unavailable')
        if row.get('tag_matches') is False or row.get('active_in_hierarchy') is False:
            continue
        if row.get('active_in_hierarchy') is not True:
            raise MapReadPending('current handler active hierarchy unavailable')
        if row.get('lossy_scale_threshold_verified') is not True:
            raise MapReadPending('current handler scale threshold not source verified')
        scale = row.get('lossy_scale')
        if not isinstance(scale, (tuple,list)) or len(scale)!=3 or not all(math.isfinite(v) for v in scale):
            raise ValueError('invalid current handler lossyScale')
        if sum(v*v for v in scale) < 9.999999439624929e-11:
            continue
        if row.get('disposed') is True:
            continue
        if row.get('disposed') is not False:
            raise MapReadPending('current RootObject lifecycle unavailable')
        if any(type(row.get(k)) is not int for k in ('sorting_order','depth')):
            raise ValueError('current handler sort/depth missing')
        eligible.append(row)
    if not eligible:
        raise MapReadPending('no current eligible BackButton handler')
    order = max((r['sorting_order'],r['depth']) for r in eligible)
    best = [r for r in eligible if (r['sorting_order'],r['depth'])==order]
    if len(best)!=1:
        raise MapReadPending('equal BackButton order/depth requires actual tag enumeration order')
    return best[0]


class EscapeView:
    """Typed projection over an already open QuestUIActionSession."""
    def __init__(self, session):
        self.s=session; self.r=session.reader; self.source=escape_source()
        if any(session.plan.get(k)!=self.source[k] for k in ('game_assembly_sha256','metadata_sha256')):
            raise ValueError('material escape source/transport version mismatch')

    def shape(self,name):
        rows=[x for x in self.source['classes'] if x['name']==name]
        if len(rows)!=1:raise ValueError('ambiguous escape type '+name)
        return rows[0]

    def fields(self,pointer,name):
        if not pointer:raise MapReadPending('current escape object absent: '+name)
        klass=self.r.u64(pointer);shape=self.shape(name)
        for _ in range(8):
            if self.r.class_name(klass)==(shape['name'],shape['namespace']):
                return self.s.fields(klass,shape)
            klass=self.r.u64(klass+0x58)
            if not klass:break
        raise ValueError('current escape ancestor mismatch: '+name)

    def value(self,pointer,name,member,fmt=None):
        fields=self.fields(pointer,name)
        row=next(f for f in self.shape(name)['fields'] if f['name']==member)
        code=row['type']['type_code']
        if fmt is None:
            fmt={2:'<?',8:'<i',9:'<I',14:'<Q',17:'<i',18:'<Q',21:'<Q',24:'<Q',28:'<Q',29:'<Q'}.get(code)
        if not fmt:raise ValueError('unsupported escape scalar '+member)
        raw=self.s.watch(pointer+fields[member],struct.calcsize(fmt))
        if code==2 and raw not in (b'\0',b'\1'):raise ValueError('invalid current escape bool')
        return struct.unpack(fmt,raw)[0]

    def singleton(self,name):
        # The source root slot is already validated by the retained session plan.
        source=self.s.schema(name);klass=_class(self.r,self.s.base,source)
        fields=self.s.fields(klass,source)
        statics=self.r.u64(klass+0xb8)
        pointer=struct.unpack('<Q',self.s.watch(statics+fields['instance'],8))[0]
        if not pointer or self.r.u64(pointer)!=klass:raise MapReadPending('current singleton absent: '+name)
        return pointer

    def verify_methods(self):
        for fn in self.source['methods']:
            raw=bytes.fromhex(fn['raw_hex'])
            if hashlib.sha256(raw).hexdigest()!=fn['sha256']:
                raise ValueError('packaged escape method hash changed')
            if self.s.watch(self.s.base+fn['rva'],len(raw))!=raw:
                raise ValueError('loaded escape method changed: '+fn['owner']+'.'+fn['name'])

    def count(self,pointer,limit=256):
        if not pointer:raise MapReadPending('current escape collection absent')
        klass=self.r.u64(pointer);name,namespace=self.r.class_name(klass)
        if name not in ('List`1','MMList`1','MMReadOnlyList`1','Queue`1'):
            raise ValueError('unsupported escape collection '+name)
        for _ in range(4):
            fields={f['name'].lstrip('_'):f for f in self.r.layout(klass)['fields']}
            if 'size' in fields and 'version' in fields:break
            klass=self.r.u64(klass+0x58)
        else:raise ValueError('current collection size/version unavailable')
        if any(fields[k]['type_code']!=8 for k in ('size','version')):raise ValueError('collection counter type changed')
        count=struct.unpack('<i',self.s.watch(pointer+fields['size']['offset'],4))[0]
        self.s.watch(pointer+fields['version']['offset'],4)
        if not 0<=count<=limit:raise ValueError('current escape collection outside bound')
        return count

    def stage_dispatch(self):
        presentation=self.singleton('PresentationSingleton');framework=self.singleton('FrameworkSingleton')
        waiting=self.value(framework,'FrameworkSingleton','<WaitingIndicator>k__BackingField')
        if self.count(self.value(waiting,'WaitingIndicatorAPI','handles')):
            raise MapReadPending('waiting indicator owns BackButton')
        cutscene=self.value(presentation,'PresentationSingleton','<CutsceneController>k__BackingField')
        if self.value(cutscene,'CutsceneController','currentCutscenePlayable'):
            raise MapReadPending('current cutscene owns BackButton')
        service=self.value(presentation,'PresentationSingleton','<UIStateService>k__BackingField')
        if any(self.value(service,'UIStateService',k)!=0 for k in ('taskCounter','historyBackQueueCounter')):
            raise MapReadPending('current UI state transition/history queue busy')
        hub=self.value(service,'UIStateService','ruleHub')
        rules=self.value(self.value(hub,'UIStateRuleHub','instances'),'UIStateRuleInstancePrioritySortedList','list')
        names=[]
        for instance in self.s.refs(rules,element='UIStateRuleInstance',limit=16):
            rule=self.value(instance,'UIStateRuleInstance','<Rule>k__BackingField')
            name,namespace=self.r.class_name(self.r.u64(rule));names.append(name)
            expected={'StageUIStateRule':'MM.Client.Game.Integration.Instances.UI.StageScene',
                'CommonUIStateRule':'MM.Client.Game.Integration.Instances.UI.Common'}
            if expected.get(name)!=namespace:
                raise MapReadPending('current earlier UI state rule not supported: '+namespace+'.'+name)
        if names.count('StageUIStateRule')!=1:raise MapReadPending('current single Stage UI rule required')
        group=self.value(service,'UIStateService','stackGroup')
        if self.value(group,'UIStateStackGroup','pauseDepth')!=0:raise MapReadPending('current UI stack processing paused')
        table=self.value(group,'UIStateStackGroup','stackTable')
        stacks=dict(self.s.dictionary_values(table,limit=16));counts={}
        for name in ('Notice','Blocker','Popup'):
            key=self.source['enums']['UIStateStackType'][name]
            pointer=stacks.get(key)
            if not pointer:raise MapReadPending('current UI stack absent: '+name)
            counts[name]=self.count(self.value(pointer,'UIStateStack','stateList'))
        if counts['Notice'] or counts['Blocker'] or not counts['Popup']:
            raise MapReadPending('current Stage branch does not dispatch popup BackButton')
        return dict(service_pointer=service,rule_names=names,stack_counts=counts)

    def disposed(self,root_object):
        name,namespace=self.r.class_name(self.r.u64(root_object))
        if name in ('UIChildTransform','UIGameObject'):
            virtual=self.value(root_object,name,'virtualObject')
        elif name=='VirtualUIObjectNode':virtual=root_object
        else:raise MapReadPending('current handler RootObject lifecycle implementation: '+name)
        return self.value(virtual,'VirtualUIObjectNode','<IsDisposed>k__BackingField')

    def callback(self,delegate,depth=0):
        if depth>2:raise ValueError('close callback wrapper bound exceeded')
        if self.value(delegate,'MulticastDelegate','delegates'):
            raise MapReadPending('multicast close callback needs exact invocation-list ownership')
        method=self.value(delegate,'Delegate','method_ptr')
        target=self.value(delegate,'Delegate','m_target')
        # Exact ClickEventService wrapper: invokes its action, without changes.
        if method==self.s.base+0x16a8d60:
            inner=self.value(target,'<>c__DisplayClass10_0','action')
            return self.callback(inner,depth+1)
        return dict(callback_pointer=delegate,callback_method=method,callback_target=target)

    def registry(self,frame):
        from .material_escape_native import read_back_graphic_native
        system=self.s.ui_system_root()
        binder=self.s.field(system,'UISystem','uiExecuteBinder')
        if self.value(binder,'UIExecuteBinder','reservedHandler'):
            raise MapReadPending('a BackButton handler is already reserved')
        if self.count(self.value(binder,'UIExecuteBinder','pendingRregistrations')):
            raise MapReadPending('current UI execute registrations pending')
        table=self.value(binder,'UIExecuteBinder','executeBinders')
        rows=self.s.dictionary_values(table,limit=16)
        handlers=[p for key,p in rows if key==self.source['enums']['UIExecuteType']['BackButton']]
        if len(handlers)!=1:raise MapReadPending('current BackButton binder not unique')
        back=handlers[0];fields=self.fields(back,'UIBackButtonExecuteBinder')
        klass=self.r.u64(back);statics=self.r.u64(klass+0xb8)
        string=struct.unpack('<Q',self.s.watch(statics+fields['BackButtonTag'],8))[0]
        tag=self.r.string(string)
        if not tag:raise ValueError('current BackButton tag empty')
        dictionary=self.value(back,'UIBackButtonExecuteBinder','managedIds');out=[]
        for instance_id,handler in self.s.dictionary_values(dictionary,limit=128):
            name,namespace=self.r.class_name(self.r.u64(handler))
            if name=='UIExecuteDefaultHandler':
                root_object=self.value(handler,name,'<RootObject>k__BackingField')
                graphic=self.value(handler,name,'<TargetGraphic>k__BackingField')
                execute_type=self.value(handler,name,'<ExecuteType>k__BackingField')
                callback=self.value(handler,name,'execute')
            elif name=='ButtonInputControl':
                button=self.value(handler,name,'buttonControl')
                root_object=self.value(button,'ButtonControl','<RootObject>k__BackingField')
                graphic=self.s.slot_raw(self.value(button,'ButtonControl','<RaycastTarget>k__BackingField'))
                execute_type=self.value(button,'ButtonControl','<ExecuteType>k__BackingField')
                callback=0
            else:raise MapReadPending('unimplemented current BackButton handler type: '+name)
            if execute_type!=1:raise ValueError('registered BackButton handler type changed')
            native=read_back_graphic_native(self.s,graphic,frame,back_tag=tag)
            if native['instance_id']!=instance_id:raise ValueError('current registered graphic instance ID differs')
            out.append(dict(native,handler_pointer=handler,root_object_pointer=root_object,
                graphic_pointer=graphic,disposed=self.disposed(root_object),callback_delegate=callback))
        return binder,back,out

    def owned_panel(self,binding,*,require_back=True):
        from .processing_observer import CurrentProcessingSession
        from .craft_observer import CurrentCraftSession,Model
        kind=binding['source_family'];cls={'native_alter':CurrentProcessingSession,'native_craft':CurrentCraftSession}.get(kind)
        if not cls:raise ValueError('unknown escape source family')
        proxy=cls.__new__(cls);proxy.__dict__=self.s.__dict__
        if kind=='native_alter':
            panels=proxy.active_processing_panels();name='AlteringPanelPopupUIInstance'
            recipe=proxy.selected_recipe_id(panels[name]) if name in panels else None
        else:
            panels=proxy.active_crafting_panels();name='CraftingPanelPopupUIInstance'
            if 'CraftResultUIInstance' in panels:raise MapReadPending('craft result owns current surface')
            recipe=proxy.selected_craft_id(panels[name]) if name in panels else None
        pointer=panels.get(name)
        if pointer!=binding['target_ui_pointer']:raise MapReadPending('current owned detail root changed')
        handle=self.value(pointer,'MMMLPopupUIInstanceBase','handle')
        if (self.value(handle,'MMMLUIHandle','shouldActive') is not True or
            (require_back and self.value(handle,'MMMLUIHandle','shouldDisableBackButton') is not False)):
            raise MapReadPending('current popup BackButton inactive or disabled')
        if proxy.window_id(handle)!=binding['window_id']:raise MapReadPending('current popup window changed')
        model=proxy.processing_model(handle) if kind=='native_alter' else proxy.crafting_model(handle)
        values=Model(model).values(Model(model).root);event=values.get('closeEvent') or {}
        if event.get('event_present') is not True or event.get('event_id')!=binding['event_id']:
            raise MapReadPending('current popup close model callback changed')
        return dict(pointer=pointer,handle=handle,recipe=recipe,event_id=event['event_id'])

    def observe(self,binding,frame):
        self.verify_methods();panel=self.owned_panel(binding);stage=self.stage_dispatch()
        binder,back,rows=self.registry(frame);chosen=select_primary_handler(rows)
        if not chosen['callback_delegate']:raise MapReadPending('selected ButtonInputControl callback owner requires source binding')
        callback=self.callback(chosen['callback_delegate'])
        owned=((callback['callback_target']==panel['pointer'] and callback['callback_method']==self.s.base+0xae7d5d0)
            or (callback['callback_target']==panel['handle'] and callback['callback_method']==self.s.base+0xaea3f70))
        if not owned:raise MapReadPending('selected current execute callback is not the exact owned popup Close/BeginClose')
        if any(self.r.read(a,len(raw))!=raw for a,raw in self.s._guards):
            raise MapReadPending('current escape receiver changed during observation')
        identity=dict(target_ui_pointer=panel['pointer'],handler_pointer=chosen['handler_pointer'],
            root_object_pointer=chosen['root_object_pointer'],target_game_object=chosen['game_object'],**callback)
        scene_rows=[{k:r.get(k) for k in ('game_object','instance_id','tag','active_in_hierarchy',
            'lossy_scale','sorting_order','depth','disposed','handler_pointer','root_object_pointer',
            'graphic_pointer','callback_delegate')} for r in rows]
        scene=dict(stage=stage,registry=scene_rows,binder_pointer=binder,back_binder_pointer=back,panel=panel,
            source_family=binding['source_family'],recipe_id=binding['recipe_id'],identity=identity)
        return dict(identity,scene_signature=_signature(scene),raw_recipe_id=panel['recipe'])


def read_escape_receiver(session,binding,frame):
    """Last-moment guard. No new process session and no UI/native invocation."""
    if binding.get('source_guard_kind')!='material-escape' or binding.get('target_id')!=TARGET_ID:
        raise ValueError('wrong owned escape guard target')
    if binding.get('source_revision')!=MMML_REVISION:raise ValueError('owned escape MMML source changed')
    for key in ('input_epoch','frame_id','window_signature'):
        actual=(frame.get('input_owner') or {}).get('epoch') if key=='input_epoch' else frame.get(key)
        if actual!=binding.get(key):raise ValueError('owned escape current frame/epoch changed: '+key)
    current=EscapeView(session).observe(binding,frame)
    for key,value in current.items():
        if binding.get(key)!=value:raise MapReadPending('owned escape receiver identity/scene changed: '+key)
    return dict(deepcopy(binding),confirmed=True,observed_at=datetime.now(timezone.utc).isoformat())


def attach_material_escape(session,ui,frame):
    """Attach source-owned receiver evidence while the UI read session is open."""
    if ui.get('surface')!='detail':return ui
    try:
        pointer=ui.get('ui_pointer',ui.get('panel_id'))
        pointer=int(pointer,16) if isinstance(pointer,str) else pointer
        binding=dict(target_id=TARGET_ID,source_guard_kind='material-escape',source_family=ui['source_family'],
            recipe_id=ui['recipe_id'],target_ui_pointer=pointer,window_id=ui['window_id'],
            event_id=ui['close']['callback']['event_id'],source_revision=ui['source_revision'],
            source_refs=[SOURCE_REF+'#sha256='+hashlib.sha256((root()/SOURCE_REF).read_bytes()).hexdigest()],
            input_epoch=(frame.get('input_owner') or {}).get('epoch'),frame_id=frame['frame_id'],
            window_signature=frame['window_signature'],observed_at=ui['observed_at'])
        if binding['input_epoch'] is None:raise MapReadPending('current escape input epoch absent')
        binding.update(EscapeView(session).observe(binding,frame))
        receiver=dict(binding,confirmed=True,key='ESC',panel_id=ui['panel_id'],source='current source-pinned Stage/BackButton registry and exact popup callback',
            **{k:ui[k] for k in ('session_id','character_id','process_started_at')})
        ui.update(key_receiver_confirmed=True,key_receiver=receiver,escape_binding=binding)
    except (OSError,RuntimeError,ValueError,KeyError,TypeError,AttributeError) as exc:
        ui['key_receiver_confirmed']=False
        ui.setdefault('close_missing',[]).append('current owned escape receiver: '+str(exc))
    return ui
