"""Current owned GroupTagNPC members. Import/construction never opens a process.

Candidates prove group membership and a current generated NPC instance. They do
not prove UI selection, interaction permission, quest effects or server success.
"""
from mini_masukja.native_type_reference import type_definition_index
from copy import deepcopy
from datetime import datetime,timezone
from functools import lru_cache
import hashlib,json,math,struct
from .paths import root
from .world_target_observer import WorldTargetSession,OWNER,VERSION,KEY_TYPE,entity_key
from .vendor.map_character_context import active_context,_class
from .vendor.map_memory import MapReadPending

SOURCE='current_owned_group_NPC_entity'
SOURCE_REF='data/arbeit-group-npc/source-pins.json'

@lru_cache(maxsize=1)
def source_pins():
    raw=(root()/SOURCE_REF).read_bytes();digest=hashlib.sha256(raw).hexdigest()
    if digest!='64f1aec045ecb20b274e86beaf0485bd2f4c1afc1c15400c2d483d6e66e96196':
        raise ValueError('group NPC source pins changed')
    value=json.loads(raw);value['source_hash']=digest;return value


class GroupNPCSession(WorldTargetSession):
    def source(self,name):
        rows=[r for r in source_pins()['classes'] if r['name']==name]
        return rows[0] if len(rows)==1 else super().source(name)

    def verify_native(self):
        super().verify_native()
        for row in source_pins()['native_probes']:
            raw=bytes.fromhex(row['raw_hex'])
            if not raw or self.watch(self.base+row['rva'],len(raw))!=raw:
                raise ValueError('group NPC native source changed:'+row['name'])

    def string(self,pointer):
        if not pointer:raise MapReadPending('current group string missing')
        if self.reader.class_name(self.u64(pointer))!=('String','System'):
            raise ValueError('current group string type changed')
        count=self.i32(pointer+16)
        if not 0<=count<=2048:raise ValueError('current group string exceeds bound')
        return self.watch(pointer+20,count*2).decode('utf-16-le')

    def _closed_field_class(self,pointer,owner,field,argument):
        schema=self.source(owner);f=next(x for x in schema['fields'] if x['name']==field)
        self.wfields(pointer,owner);klass=self.u64(pointer);table=self.u64(klass+0x80)
        tp=self.u64(table+32*f['index']+8);generic,bits=struct.unpack('<QI',self.watch(tp,12))
        if (bits>>16)&255!=21:raise ValueError('current group generic field changed:'+field)
        values=struct.unpack('<4Q',self.watch(generic,32));inst,closed=values[1],values[3]
        if not inst or not closed:raise MapReadPending('current group closed generic uninitialized:'+field)
        count,_,argv=struct.unpack('<IIQ',self.watch(inst,16))
        if count!=1 or not argv:raise ValueError('current group generic arity changed:'+field)
        definition,flags=struct.unpack('<QI',self.watch(self.u64(argv),12))
        if type_definition_index(self, self.u64(argv))!=argument or (flags>>16)&255!=18:raise ValueError('current group generic argument changed:'+field)
        return closed

    def members(self,group_name):
        if not isinstance(group_name,str) or not group_name or len(group_name)>2048:
            raise ValueError('current exact nonempty NPC group name required')
        r=self.reader;schema=self.source('ClientStaticDataHolder');klass=_class(r,self.base,schema)
        slots=[at for at in schema['class_slot_rvas'] if self.u64(self.base+at)==klass]
        if not slots:raise MapReadPending('current group static holder class changed')
        fields=self.fields(klass,schema);statics=self.u64(klass+0xb8)
        if not statics:raise MapReadPending('current group static holder uninitialized')
        holder=self.u64(statics+fields['<NPCs>k__BackingField'])
        f=self.wfields(holder,'NPCSheetDataHolder');items=self.u64(holder+f['npcs'])
        if not items:raise MapReadPending('current NPC list unavailable')
        closed=self._closed_field_class(holder,'NPCSheetDataHolder','npcs',62809)
        actual=self.u64(items)
        if actual!=closed:
            if r.class_name(actual)!=('MMList`1','Silvervine.ManualMemory') or self.u64(actual+0x58)!=closed:
                raise ValueError('current NPC list closed class changed')
        if r.class_name(closed)!=('MMReadOnlyList`1','Silvervine.ManualMemory'):
            raise ValueError('current NPC list base changed')
        layout=r.layout(closed);lf={x['name']:x for x in layout['fields']}
        if set(lf)!={'array','version','size'} or any(lf[n].get('type_code')!=c for n,c in [('array',29),('version',8),('size',8)]):
            raise ValueError('current NPC list fields changed')
        if any(not 16<=x['offset']<=128 for x in lf.values()):raise ValueError('current NPC list offsets invalid')
        count=self.i32(items+lf['size']['offset']);self.i32(items+lf['version']['offset'])
        if not 0<=count<=16890:raise ValueError('current NPC list size outside bound')
        array=self.u64(items+lf['array']['offset']);length,_=self.array(array,name='NPCSheetData[]',stride=8,limit=16890)
        self.watch(array,32)
        if length<count:raise ValueError('current NPC list array truncated')
        result=[];ids=set()
        for pointer in struct.unpack('<'+'Q'*count,self.watch(array+32,count*8)) if count else []:
            nf=self.wfields(pointer,'NPCSheetData');identity=self.i32(pointer+nf['Id'])
            group=self.u64(pointer+nf['NPCGroupId'])
            # NPCGroupId is inline UniqueString {actualString,hashCode}. The
            # constructor groups these exact rows by that source identifier.
            if not group or self.string(group)!=group_name:continue
            if identity in ids:raise ValueError('current NPC group has duplicate definition IDs')
            ids.add(identity)
            result.append(dict(group_name=group_name,npc_id=identity,verified=True,source_ref=SOURCE_REF,
                row_pointer=pointer,group_string_pointer=group,holder_pointer=holder,
                basis='current NPCSheetData.NPCGroupId exact identifier string; no display text or synthetic hash'))
        return result

    def _stage_positions(self,system):
        """Actual same-key local locations, not replicated path origins."""
        r=self.reader;sf=self.wfields(system,'SystemContainers');p=self.u64(system+sf['StageObjectEntityContainer'])
        if not p:raise MapReadPending('current stage entity container missing')
        klass=self._closed_field_class(system,'SystemContainers','StageObjectEntityContainer',30245)
        if self.u64(p)!=klass or r.class_name(klass)!=('EntityContainer`1','MM.Client.Framework.ComponentProcessing.Containers'):
            raise ValueError('current stage container type changed')
        method=self.u64(self.base+source_pins()['stage_container']['method_cache_rva'])
        if not method or self.u64(method+0x20)!=klass:raise MapReadPending('current stage method cache unavailable')
        rgctx=self.u64(klass+0xc0)
        if not rgctx:raise MapReadPending('current stage generic methods unavailable')
        for slot,rva in ((24,0x4eb6290),(32,0x4eb5e50),(48,0x2b57a10),(56,0x15ddae0)):
            entry=self.u64(rgctx+slot)
            if not entry or self.u64(entry+8)!=self.base+rva or self.u64(entry+0x20)!=klass:
                raise ValueError('current stage generic native method changed')
        layout={f['name']:f for f in r.layout(klass)['fields']}
        for n,o,c in [('EntityType',16,17),('<LastIndex>k__BackingField',20,8),('<Entities>k__BackingField',24,29),('ages',32,29)]:
            if n not in layout or (layout[n]['offset'],layout[n]['type_code'])!=(o,c):raise ValueError('current stage container layout changed')
        kind=self.i32(p+16);last=self.i32(p+20)
        if kind!=self.source('MultipleEntityType')['enum_constants']['StageObject'] or not -1<=last<65536:
            raise ValueError('current stage entity kind/index invalid')
        array=self.u64(p+24);ages=self.u64(p+32)
        count,_=self.array(array,name='StageObjectEntity[]',stride=8,limit=65536)
        acount,_=self.array(ages,name='UInt16[]',stride=2,limit=65536)
        self.watch(array,32);self.watch(ages,32)
        if count<=last or acount<=last:raise ValueError('current stage arrays truncated')
        pointers=struct.unpack('<'+'Q'*(last+1),self.watch(array+32,(last+1)*8)) if last>=0 else []
        generations=struct.unpack('<'+'H'*(last+1),self.watch(ages+32,(last+1)*2)) if last>=0 else []
        result={}
        for index,pointer in enumerate(pointers):
            if not pointer:continue
            f=self.wfields(pointer,'StageObjectEntity');location=self.u64(pointer+f['IStageObjectLocationComponent'])
            if not location:continue
            actual=self.u64(location)
            if r.class_name(actual)!=('ActiveStageObjectLocationComponent',self.source('ActiveStageObjectLocationComponent')['namespace']):continue
            self.wfields(location,'ActiveStageObjectLocationComponent')
            parent=self.u64(actual+0x58);schema=self.source('IStageObjectLocationComponent')
            if r.class_name(parent)!=(schema['name'],schema['namespace']):raise ValueError('current NPC location parent changed')
            lf=self.fields(parent,schema);x,y=struct.unpack('<ff',self.watch(location+lf['position'],8))
            if not all(math.isfinite(v) for v in (x,y)):raise ValueError('nonfinite current NPC position')
            key=struct.pack('<II',kind,(index<<16)|generations[index])
            result[key]=dict(location_pointer=location,stage_entity_pointer=pointer,
                position=dict(coordinate_space='System.Numerics.Vector2/current channel',x=x,y=y))
        return result

    def spawned_npcs(self,replicated,system,channel,*,members=None):
        positions=self._stage_positions(system);result=[];seen=set()
        by_id={r['npc_id']:r for r in members} if members is not None else None
        for slot in self.container(replicated):
            pointer=slot['pointer'];f=self.wfields(pointer,'MultipleReplicatedEntity')
            if not self.bit(pointer+f['AutomatonIdentityReplicatedUpdateState']):continue
            ident=self.u64(pointer+f['AutomatonIdentityReplicated']);fields=self.wfields(ident,'AutomatonIdentityReplicated')
            key=self.watch(ident+fields['<EntityKey>k__BackingField'],8)
            if key!=slot['key']:raise MapReadPending('current NPC identity generation differs')
            if self.i32(ident+fields['<ActorType>k__BackingField'])!=self.source('ActorType')['enum_constants']['NPC']:continue
            definition=self.i32(ident+fields['<NPCSheetId>k__BackingField'])
            if by_id is not None and definition not in by_id:continue
            if key not in positions:continue
            if key in seen:raise ValueError('duplicate current NPC generation key')
            seen.add(key)
            target=dict(domain='NPC',definition_id=definition,table_domain='NPCSheet',entity_key=entity_key(key),
                current_channel=dict(table_domain='ChannelSheet',id=channel),identity_pointer=ident,entity_pointer=pointer,
                collection_index=slot['index'],collection_age=slot['age'],replicated_present=True,membership_verified=True,
                activation_confirmed=False,movement_ready=False,**positions[key])
            if by_id is not None:target['group_membership']=deepcopy(by_id[definition])
            result.append(target)
        return result

    def spawned(self,replicated,channel,*,definition_id=None):
        context=active_context(self.reader,self.base,self.character_plan)
        members=self.members(self.current_group_name)
        self.current_members=members
        return self.spawned_npcs(replicated,context['chain'][2],channel,members=members)

    def read_group_targets(self,*,group_name,current_map_snapshot=None):
        self.current_group_name=group_name;self.current_members=[]
        result=super().read_targets(current_map_snapshot=current_map_snapshot)
        result.update(source=SOURCE,source_ref=SOURCE_REF,source_hash=source_pins()['source_hash'],
            group_name=group_name,members=deepcopy(self.current_members))
        return result


def owned_group_program(node,snapshot):
    from .objective_binding import current_objective_occurrence
    from .typed_objective_operands import normalize_objective_operands
    rows=[p for p in snapshot.get('objective_programs',[]) if current_objective_occurrence(p,snapshot,node)]
    if len(rows)!=1:raise ValueError('one fresh current owned group NPC objective required')
    program=rows[0];normal=normalize_objective_operands(program,expected=node)
    group=normal['operands'].get('npc_group_tag') or {}
    if (not normal['ready'] or normal['operation'] not in ('dialogue.talk','objective.deliver')
        or type(group.get('value')) is not str or not group['value']):
        raise ValueError('current typed NPC group string required:'+','.join(normal['missing']))
    auto=program.get('auto_play') or {};auto_group=(auto.get('values') or {}).get('NpcGroupTagName')
    if auto_group is not None and (auto.get('available') is not True or auto_group.get('interpreted') is not True
        or auto_group.get('declared_type')!='System.String' or auto_group.get('value')!=group['value']):
        raise ValueError('current objective Data/AutoPlay NPC group mismatch')
    return program,normal


def bind_current_group_npc_targets(node,snapshot,observation):
    from .arbeit_operations import _fresh
    program,normal=owned_group_program(node,snapshot);pins=source_pins();group=normal['operands']['npc_group_tag']
    if (observation.get('stable') is not True or observation.get('source')!=SOURCE or observation.get('source_hash')!=pins['source_hash']
        or any((observation.get('source_version') or {}).get(k)!=pins[k] for k in VERSION)
        or observation.get('group_name')!=group['value'] or observation.get('process_started_at')!=program['process_started_at']
        or str((observation.get('identity') or {}).get('character_id'))!=str(program['character_id'])
        or not _fresh(dict(observation,session_id=program['session_id'],character_id=program['character_id']),snapshot)):
        raise ValueError('current group NPC observation owner/time/source mismatch')
    rows=[]
    for target in observation['targets']:
        key=target.get('entity_key') or {};membership=target.get('group_membership') or {}
        if (target.get('domain')!='NPC' or target.get('table_domain')!='NPCSheet' or target.get('replicated_present') is not True
            or target.get('membership_verified') is not True or membership.get('verified') is not True
            or membership.get('group_name')!=group['value'] or membership.get('npc_id')!=target.get('definition_id')
            or key.get('declared_type')!=KEY_TYPE or key.get('raw_hex')!=struct.pack('<ii',key['Type'],key['InnerKey']).hex()
            or (target.get('current_channel') or {}).get('id')!=observation['current_channel']):
            raise ValueError('current group NPC candidate identity/membership invalid')
        stamp=min(datetime.fromisoformat(r['observed_at'].replace('Z','+00:00')) for r in (program,observation)).isoformat()
        rows.append(dict({k:deepcopy(program[k]) for k in OWNER},node_id=node['id'],confirmed=True,owner_verified=True,
            source=SOURCE,source_ref=SOURCE_REF,source_hash=pins['source_hash'],source_version=deepcopy(observation['source_version']),
            observed_at=stamp,group_tag=deepcopy(group),npc_id=target['definition_id'],membership=deepcopy(membership),
            target=deepcopy(target),objective_source=program['source'],objective_observed_at=program['observed_at'],
            normalized_operands=deepcopy(normal),activation_confirmed=False,interaction_ready=False,movement_ready=False))
    return rows


class GroupNPCObservationProvider:
    def __init__(self,*,allow_process_read=False,session_factory=None):
        self.enabled=allow_process_read is True;self.session_factory=session_factory or GroupNPCSession
    def __call__(self,*,window,node,snapshot,frame,image=None):
        result=dict(ok=False,current_group_npc_targets=[],current_group_npc_observation={},missing=[],read_attempted=False,
            game_inputs=0,memory_writes=0,client_functions_called=0,heap_scans=0,live_tested=False)
        if not self.enabled:result['missing']=['process_read_not_explicitly_enabled'];return result
        try:
            p,n=owned_group_program(node,snapshot)
            pid=getattr(window,'pid',None) or frame.get('pid') or (frame.get('window') or {}).get('pid')
            if type(pid) is not int or pid<=0:raise ValueError('bound group NPC PID required')
            if any(frame.get(k)!=p.get(k) for k in ('process_started_at','character_id','session_id')):raise ValueError('bound group NPC owner differs')
            result['read_attempted']=True
            with self.session_factory(pid) as session:
                outer=snapshot.get('map') or {};current=outer.get('snapshot') if outer.get('ok') is True else None
                observation=session.read_group_targets(group_name=n['operands']['npc_group_tag']['value'],current_map_snapshot=current)
            if observation.get('pid')!=pid:raise ValueError('group NPC process changed')
            rows=bind_current_group_npc_targets(node,snapshot,observation)
            result.update(ok=True,current_group_npc_targets=rows,current_group_npc_observation=observation)
            if not rows:result['missing']=['current_group_has_no_present_NPC_with_current_local_position']
        except (OSError,RuntimeError,ValueError,TypeError,KeyError,AttributeError,struct.error) as exc:result['missing']=[str(exc)]
        return result


def read_current_npc_target(session,key,npc_id,*,context=None,group_name=None):
    """Read within the caller's existing session; caller performs final guards."""
    if type(npc_id) is not int or key.get('declared_type')!=KEY_TYPE or key.get('raw_hex')!=struct.pack('<ii',key['Type'],key['InnerKey']).hex():
        raise ValueError('current exact NPC definition/key required')
    view=GroupNPCSession.__new__(GroupNPCSession)
    for k in ('reader','base','plan','character_plan','_guards'):setattr(view,k,getattr(session,k))
    view.verify_native();context=context or active_context(view.reader,view.base,view.character_plan)
    rep=view.wref(context['chain'][1],'IntegrationSingleton','<Replicated>k__BackingField')
    channel=view.wref(context['chain'][3],'SingularEntity','IChannelIdentifierComponent');fields=view.wfields(channel,'ChannelIdentifierComponent')
    if view.bit(channel+fields['<IsChangingChannel>k__BackingField']):raise MapReadPending('current NPC channel changing')
    channel_id=view.i32(channel+fields['<ChannelSheetId>k__BackingField'])
    members=view.members(group_name) if group_name is not None else None
    rows=[r for r in view.spawned_npcs(rep,context['chain'][2],channel_id,members=members)
          if r['definition_id']==npc_id and r['entity_key']==key]
    if len(rows)!=1:raise MapReadPending('current NPC removed/recycled or membership changed')
    return dict(confirmed=True,source=SOURCE,source_ref=SOURCE_REF,source_hash=source_pins()['source_hash'],
        source_version={k:view.plan[k] for k in VERSION},observed_at=datetime.now(timezone.utc).isoformat(),
        target=rows[0],caller_final_stability_check_required=True)


def verify_current_npc_target(session,target,*,context=None):
    if target.get('domain')!='NPC':raise ValueError('current NPC target domain required')
    result=read_current_npc_target(session,target['entity_key'],target['definition_id'],context=context,
        group_name=(target.get('group_membership') or {}).get('group_name'))
    if result['target']['current_channel']!=target.get('current_channel'):raise MapReadPending('current NPC channel changed')
    return result


def verify_current_group_npc_target(session,target,*,context=None):
    if not (target.get('group_membership') or {}).get('group_name'):raise ValueError('current NPC group membership required')
    return verify_current_npc_target(session,target,context=context)
