"""Current server StoryBox pages and selected-goal UI adoption.

This module reads named fields through an existing guarded session. It never
localizes a token, supplies an absent server default, invokes a game function,
or declares that a UI belongs to a server quest.
"""
from copy import copy,deepcopy
from functools import lru_cache
import hashlib,json,math,struct
from .paths import root
from .vendor.map_memory import MapReadPending

SOURCE_REF='data/r6-story-read/source-pins.json'
SOURCE='current_root_StoryBoxReplicated_server_pages'
ADOPTION='selected_goal_story_ui_adoption'
SCOPE=('quest_id','instance_id','accept_time','step_id','step_index','objective_index','process_started_at')

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

@lru_cache(maxsize=1)
def source():return json.loads((root()/SOURCE_REF).read_text(encoding='utf8'))

class StoryReadView:
    def __init__(self,session):
        self.s=session;self.r=session.reader;self.pins=source()
        # MapMemorySession caches by (klass,type_index), independent of which
        # sparse source fields were validated. This helper owns a fresh cache;
        # it shares the reader and observation guards, never another session.
        self.validator=copy(session);self.validator._validated={}
        if any(session.plan.get(k)!=self.pins[k] for k in ('game_assembly_sha256','metadata_sha256')):
            raise ValueError('current replicated story source revision changed')
        from .story_ui_observer import story_source
        if (story_source().get('server_page_source') or {}).get('source_hash')!=self.pins['source_hash']:
            raise ValueError('packaged story/server page source dependency differs')

    def shape(self,name):return next(c for c in self.pins['classes'] if c['name']==name)

    def fields(self,pointer,name):
        shape=self.shape(name);klass=self.r.u64(pointer)
        if self.r.class_name(klass)!=(name,shape['namespace']):raise ValueError('current replicated story object changed:'+name)
        return self.validator.fields(klass,shape)

    def ref(self,pointer,name,field):
        return struct.unpack('<Q',self.s.watch(pointer+self.fields(pointer,name)[field],8))[0]

    def read(self,frame,*,character_context):
        from .vendor.map_character_context import _class
        from .story_ui_observer import StoryView
        s=self.s;r=self.r
        for fn in self.pins['methods']:
            raw=bytes.fromhex(fn['raw_hex'])
            if s.watch(s.base+fn['rva'],len(raw))!=raw:raise ValueError('current story page transfer method changed')
        shape=self.shape('IntegrationSingleton');klass=_class(r,s.base,shape);fields=self.validator.fields(klass,shape)
        static=r.u64(klass+0xb8)
        singleton=struct.unpack('<Q',s.watch(static+fields['instance'],8))[0]
        if singleton!=character_context['chain'][1]:raise MapReadPending('story and character singleton differ')
        replicated=self.ref(singleton,'IntegrationSingleton','<Replicated>k__BackingField')
        singular=self.ref(replicated,'ReplicatedSystem','<SingularReplicatedEntity>k__BackingField')
        offsets=self.fields(singular,'SingularReplicatedEntity')
        # GetStoryBoxReplicated tests the first byte of ReplicatedUpdateState.
        # Preserve the exact source condition; do not interpret another flag.
        if s.watch(singular+offsets['StoryBoxReplicatedUpdateState'],1)==b'\0':
            raise MapReadPending('current replicated StoryBox is not present')
        story=self.ref(singular,'SingularReplicatedEntity','StoryBoxReplicated')
        listing=self.ref(story,'StoryBoxReplicated','Pages');lk=r.u64(listing)
        if r.class_name(lk)!=('List`1','System.Collections.Generic'):raise ValueError('current replicated story collection changed')
        fs={f['name'].lstrip('_'):f for f in r.layout(lk)['fields']}
        if any(k not in fs for k in ('items','size','version')) or tuple(fs[k]['type_code'] for k in ('items','size','version'))!=(29,8,8):
            raise ValueError('current replicated story collection layout changed')
        size=struct.unpack('<i',s.watch(listing+fs['size']['offset'],4))[0]
        version=struct.unpack('<i',s.watch(listing+fs['version']['offset'],4))[0]
        if not 1<=size<=128:raise MapReadPending('current replicated story pages absent or unbounded')
        array=struct.unpack('<Q',s.watch(listing+fs['items']['offset'],8))[0]
        count,stride=s.array(array,name='StoryBoxPageReplicatedData[]',stride=16,limit=256)
        fields=self.validator.fields(r.u64(r.u64(array)+0x40),self.shape('StoryBoxPageReplicatedData'))
        if stride!=16 or size>count or fields!={'Text':16,'Interval':24}:raise ValueError('current replicated story page layout changed')
        pages=[];strings=StoryView(s)
        for i in range(size):
            raw=s.watch(array+32+i*16,16);text,interval=struct.unpack_from('<Qf',raw)
            if not math.isfinite(interval) or interval<0:raise ValueError('current server StoryBox interval invalid')
            pages.append(dict(content=strings.text(text),stay_interval=interval))
        s.assert_stable()
        return dict(confirmed=True,source=SOURCE,source_hash=self.pins['source_hash'],
            source_version={k:self.pins[k] for k in ('game_assembly_sha256','metadata_sha256')},
            source_refs=[SOURCE_REF+'#'+self.pins['source_hash']],observed_at=frame.get('now',frame.get('captured_at')),
            **{k:frame[k] for k in ('session_id','character_id','process_started_at')},
            root_chain=[singleton,replicated,singular,story,listing,array],list_version=version,pages=pages,
            pages_fingerprint=digest(pages),server_quest_owner_read=False)

def canonical_native_receipt(frame):
    """Project actual sent ledger state without rewriting a prepared journal.

    Graph preserves the prepared planner receipt; dispatch belongs to the outer
    pending record and concrete input rows. A prepared intent by itself is not
    evidence that an input occurred.
    """
    from .story_ui_actions import planner_receipt
    from .arbeit_operations import _stamp
    pending=frame.get('pending_input') or {};receipt=planner_receipt(frame)
    if not isinstance(receipt,dict) or not receipt:return None
    if receipt.get('dispatch_state') not in ('prepared','sent_unconfirmed'):return None
    child=receipt.get('child_receipt') or {};transport=pending.get('input_receipt')
    if transport is not None and not isinstance(transport,dict):return None
    for row in (receipt,child,pending,transport or {}):
        if row.get('input_attempted') is False or row.get('attempted') is False or row.get('dry_run') is True:return None
        if row.get('status') in ('blocked','dry_run','not_sent','cancelled','unknown'):return None
        if row.get('dispatch_state') in ('blocked','dry','dry_run','not_sent','cancelled','unknown'):return None
    if transport is not None or receipt.get('dispatch_state')=='prepared':
        if pending.get('status')!='sent_unconfirmed' or not isinstance(transport,dict) or transport.get('status')!='sent_unconfirmed':return None
        rows=transport.get('receipts') or []
        if not rows:return None
        for row in rows:
            if row.get('status')!='sent_unconfirmed' or row.get('attempted') is not True or not row.get('operation_id') or not row.get('dispatch_finished_at'):return None
            try:_stamp(row['dispatch_finished_at'])
            except (ValueError,TypeError):return None
    elif receipt.get('dispatch_state')!='sent_unconfirmed':return None
    result=deepcopy(receipt);result['dispatch_state']='sent_unconfirmed';return result


def native_request_context(frame,node,program):
    """Validate a prior sent quest request and the current exact native target."""
    from .arbeit_operations import _fresh,_stamp
    from .quest_autoplay import rules
    receipt=canonical_native_receipt(frame)
    if receipt is None:return None
    child=receipt.get('child_receipt') or {}
    scope={k:deepcopy(program[k]) for k in SCOPE}
    if receipt.get('phase')!='quest_autoplay.quest_autoplay_sent' or child.get('action_scope')!='quest_instance':return None
    if receipt.get('node_id')!=node['id'] or child.get('requested_objective')!=scope:return None
    if any(receipt.get(k)!=frame.get(k) for k in ('session_id','character_id')):return None
    active=frame.get('current_autoplay') or {};pins=rules()
    if not (active.get('confirmed') is True and active.get('owner_verified') is True and active.get('active') is True):return None
    if active.get('source')!='current_root_AutoPlayComponent_server_target_and_active_fields' or active.get('source_hash')!=pins['source_hash']:return None
    if any((active.get('source_version') or {}).get(k)!=pins[k] for k in ('metadata_sha256','game_assembly_sha256')):return None
    if active.get('target')!={'kind':'quest_instance','instance_id':program['instance_id']}:return None
    if active.get('target_quest_step_index')!=program['step_index'] or active.get('target_quest_objective_index')!=program['objective_index']:return None
    if any(str(active.get(k))!=str(frame.get(k)) for k in ('session_id','character_id','process_started_at')) or not _fresh(active,frame):return None
    if not receipt.get('observed_at') or _stamp(active['observed_at'])<=_stamp(receipt['observed_at']):return None
    rows=((frame.get('pending_input') or {}).get('input_receipt') or {}).get('receipts') or []
    if any(_stamp(active['observed_at'])<=_stamp(row['dispatch_finished_at']) for row in rows):return None
    return dict(prior_receipt_fingerprint=digest(receipt),requested_objective=scope,current_autoplay=deepcopy(active))

def server_page_binding(typed,frame,node,program,receipt):
    """Server-resolved values are authority only under an owned UI adoption."""
    from .arbeit_operations import _fresh
    replicated=typed.get('replicated_story') or {};pins=source()
    if (replicated.get('confirmed') is not True or replicated.get('source')!=SOURCE or replicated.get('source_hash')!=pins['source_hash']
        or replicated.get('pages')!=typed.get('pages') or replicated.get('pages_fingerprint')!=digest(typed.get('pages'))
        or not _fresh(replicated,frame) or any(replicated.get(k)!=frame.get(k) for k in ('session_id','character_id','process_started_at'))):
        raise MapReadPending('story_localization_requires_current_owned_replicated_pages')
    saved=((receipt.get('expected_effect') or {}).get('story_binding') or {}).get('server_page_adoption')
    identity=dict(root_chain=replicated['root_chain'],pages_fingerprint=replicated['pages_fingerprint'],source_hash=replicated['source_hash'])
    scope={k:deepcopy(program[k]) for k in SCOPE}
    if saved:
        if saved.get('server_pages')!=identity or saved.get('goal_scope')!=scope:raise MapReadPending('current server story pages or selected occurrence changed')
        return deepcopy(saved)
    request=native_request_context(frame,node,program)
    if not request:raise MapReadPending('story_localization_requires_prior_owned_native_request_and_current_target')
    return dict(basis=ADOPTION,goal_scope=scope,server_pages=identity,**request,server_quest_owner_read=False)

def interaction_adoptions(typed,frame,node,bound,program):
    from .arbeit_operations import _stamp
    request=native_request_context(frame,node,program)
    if not request or typed.get('present') is not True or (typed.get('pointer_semantics') or {}).get('ui_kind')!='story':return []
    receipt=canonical_native_receipt(frame)
    if _stamp(typed['observed_at'])<=_stamp(receipt['observed_at']):return []
    rows=((frame.get('pending_input') or {}).get('input_receipt') or {}).get('receipts') or []
    if any(_stamp(typed['observed_at'])<=_stamp(row['dispatch_finished_at']) for row in rows):return []
    return [dict(kind=ADOPTION,phase=ADOPTION,basis=ADOPTION,node_id=node['id'],
        **{k:program[k] for k in SCOPE},goal_scope=deepcopy(bound['goal_scope']),**request,
        source=typed['source'],source_revision=typed['source_hash'],source_refs=deepcopy(typed['source_refs']),observed_at=typed['observed_at'],
        session_id=frame['session_id'],character_id=frame['character_id'],server_quest_owner_read=False,
        story_ui=dict(ui_pointer=typed['ui_pointer'],handle=typed['handle'],pages_fingerprint=digest(typed['pages']),
            source=typed['source'],source_revision=typed['source_hash'],observed_at=typed['observed_at'],replicated_story=deepcopy(typed.get('replicated_story'))),
        typed_goal_source=dict({k:deepcopy(program.get(k)) for k in ('source','source_hash','source_version','observed_at')},
            data_type=(program.get('data') or {}).get('type'),data_namespace=(program.get('data') or {}).get('namespace'),
            source_schema=deepcopy((program.get('data') or {}).get('source_schema'))))]
