"""Current inventory category/model navigation; never an item-use effect.

All controls originate in the active inventory's slots and the game's current
input registry. No native function is invoked and no UI ordinal is invented.
"""
from copy import deepcopy
from functools import lru_cache
import hashlib
import json
import struct

from .paths import root
from .quest_item_card_open import InventoryEntryView
from .story_ui_observer import story_source
from .vendor.map_character_context import _class
from .vendor.map_memory import MapReadPending

SOURCE = 'data/r7-inventory-navigation/source.json'


@lru_cache(maxsize=1)
def navigation_source():
    raw=(root()/SOURCE).read_bytes();value=json.loads(raw)
    value['source_hash']=hashlib.sha256(raw).hexdigest()
    return value


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


class InventoryNavigationView(InventoryEntryView):
    def __init__(self,session):
        super().__init__(session)
        extra=navigation_source()
        if any(extra[k]!=self.source[k] for k in ('metadata_sha256','game_assembly_sha256')):
            raise ValueError('inventory navigation source revision differs')
        classes={c['name']:c for c in self.source['classes']}
        classes.update({c['name']:c for c in extra['classes']})
        self.source=dict(extra,classes=[dict(c) for c in classes.values()])
        if extra['mmml']['source_sha256']!=story_source()['mmml']['source_sha256']:
            raise ValueError('inventory navigation MMML revision differs')
        for path,sha in extra.get('dependencies',{}).items():
            if hashlib.sha256((root()/path).read_bytes()).hexdigest()!=sha:
                raise ValueError('inventory navigation dependency changed: '+path)

    def static(self,name,member,size=8):
        shape=self.shape(name);klass=_class(self.r,self.s.base,shape)
        fields=self.s.fields(klass,shape);statics=self.r.u64(klass+0xb8)
        if not statics:raise MapReadPending('current inventory category statics absent')
        return self.s.watch(statics+fields[member],size)

    def int_array(self,pointer):
        n,_=self.s.array(pointer,name='Int32[]',stride=4,limit=128)
        return list(struct.unpack('<'+'i'*n,self.s.watch(pointer+32,n*4)))

    def registered_button(self,raw_slot):
        group=struct.unpack('<Q',self.static('ButtonInputControlGroup','<Default>k__BackingField'))[0]
        if not self.value(group,'ButtonInputControlGroup','interactable'):
            raise MapReadPending('current inventory input group disabled')
        if self.value(group,'ButtonInputControlGroup','pressedButtonControl'):
            raise MapReadPending('current inventory input group already pressed')
        rows=self.reference_pairs(self.value(group,'ButtonInputControlGroup','buttonInputControlMap'));found=[]
        for button,registered in rows:
            if self.value(registered,'ButtonInputControl','buttonControl')!=button or self.value(registered,'ButtonInputControl','inputHandler')!=group:
                raise MapReadPending('current registered inventory input owner changed')
            # Registry includes other IButtonControl implementations. Only the
            # actual ButtonControl registered against this source slot applies.
            sh=self.shape('ButtonControl')
            if self.r.class_name(self.r.u64(button))!=(sh['name'],sh['namespace']):continue
            slot=self.value(button,'ButtonControl','uiSlot')
            if self.s.slot_raw(slot)!=raw_slot:continue
            if any(self.value(registered,'ButtonInputControl',k) for k in ('isPressed','isLongPressed','isControlKeyPaused')):
                raise MapReadPending('current category button input busy')
            if not all(self.value(button,'ButtonControl',k) for k in ('enabled','interactable')):
                raise MapReadPending('current category button disabled')
            found.append((button,registered,group))
        if len(found)!=1:raise MapReadPending('current inventory slot has no unique registered button')
        return found[0]

    def state(self,ui,icons):
        component=self.value(ui,'EquipmentInventoryUIInstance','inventoryCategoryUIComponent')
        if not self.value(component,'InventoryCategoryUIComponent','isInitialized'):
            raise MapReadPending('current inventory categories not initialized')
        data=self.value(component,'InventoryCategoryUIComponent','mainCategoryData')
        category=self.value(component,'InventoryCategoryUIComponent','mainCategory')
        selected=self.value(data,'StaticCategoryGroupData','<SelectedUniqueId>k__BackingField')
        if category!=selected:raise MapReadPending('current inventory category model not synchronized')
        return component,data,dict(inventory_ui_pointer=ui,inventory_category=category,
            inventory_selected_category=selected,inventory_icon_revision=digest(sorted(icons)),
            inventory_navigation_source=navigation_source()['source_hash'])

    def dynamic_state(self,component,fields):
        table=self.value(component,'InventoryCategoryUIComponent','subCategoryGroupdatas')
        if not table:return None
        contexts=self.s.dictionary_values(table,key_type='EnumKey`1',value_type='CategorySelectionContext',limit=128)
        selected=[v for key,v in contexts if key==fields['inventory_category']]
        if len(selected)!=1:raise MapReadPending('current main category context is not unique')
        context=selected[0]
        default=self.value(context,'CategorySelectionContext','defaultCategory')
        current=self.value(context,'CategorySelectionContext','selectedUniqueId')
        items=self.s.refs(self.value(context,'CategorySelectionContext','Items'),element='DynamicCategoryItemData',limit=128)
        if not items or len(set(items))!=len(items):raise MapReadPending('current dynamic category items unavailable or repeated')
        fields.update(inventory_dynamic_selected=current,inventory_dynamic_default=default,
            inventory_dynamic_context=context,inventory_dynamic_items=digest(items))
        return context,items,current,default

    def closed_values(self,pointer,name):
        shape=self.shape(name);klass=self.r.u64(pointer)
        if self.r.class_name(klass)!=(shape['name'],shape['namespace']):
            raise MapReadPending('current dynamic radio implementation changed')
        rows={r['name']:r for r in self.r.layout(klass,[f['name'] for f in shape['fields']])['fields']}
        if set(rows)!={f['name'] for f in shape['fields']}:
            raise MapReadPending('current closed dynamic radio fields unavailable')
        for f in shape['fields']:
            row=rows[f['name']];expected=f['type']['type_code']
            permitted=(18,19,21) if expected==19 else (expected,)
            if row['type_code'] not in permitted or not 16<=row['offset']<=512:
                raise ValueError('current dynamic radio closed layout changed: '+f['name'])
        def get(name):
            row=rows[name];width=1 if row['type_code']==2 else 8
            raw=self.s.watch(pointer+row['offset'],width)
            if width==1:
                if raw not in (b'\0',b'\1'):raise ValueError('current dynamic radio bool invalid')
                return raw==b'\1'
            return struct.unpack('<Q',raw)[0]
        return get

    def dynamic_category(self,ui,panel,component,fields,frame,item_id,*,context):
        if context is None:return None
        context_pointer,items,current,default=context
        target=self.shape('InventorySubCategory')['enum_constants']['All']
        if fields['inventory_category']!=self.shape('InventoryMainCategory')['enum_constants']['All']:return None
        if default!=target:raise MapReadPending('current main All default subcategory differs from source All')
        if current==default:return None
        rows=[];tree={};seen=set()
        def walk(pointer):
            if pointer in seen or len(seen)>=128:raise MapReadPending('current dynamic category tree repeats or exceeds bound')
            seen.add(pointer);unique=self.value(pointer,'DynamicCategoryItemData','UniqueId')
            children=self.s.refs(self.value(pointer,'DynamicCategoryItemData','SubItems'),element='DynamicCategoryItemData',limit=128)
            values={unique}
            for child in children:values.update(walk(child))
            tree[pointer]=values;return values
        for pointer in items:
            walk(pointer)
            if self.value(pointer,'DynamicCategoryItemData','UniqueId')==target:rows.append(pointer)
        if len(rows)!=1:raise MapReadPending('current default dynamic category row is not unique')
        target_data=rows[0]
        if len(tree[target_data])!=1:raise MapReadPending('current default dynamic category opens a submenu')
        control=self.value(panel,'InventoryPanelUI','inventoryCategoryUIControl')
        slot=self.value(control,'InventoryCategoryUIControl','uiSlot')
        sub_slot=self.value(slot,'InventoryCategoryUISlot','<SubFilterCategoryGroupSlot>k__BackingField')
        if not sub_slot:raise MapReadPending('current inventory dynamic category slot unavailable')
        input_group=struct.unpack('<Q',self.static('ButtonInputControlGroup','<Default>k__BackingField'))[0]
        registered_rows=self.reference_pairs(self.value(input_group,'ButtonInputControlGroup','buttonInputControlMap'))
        matches=[]
        for button,registered in registered_rows:
            if self.r.class_name(self.r.u64(button))!=(self.shape('ButtonControl')['name'],self.shape('ButtonControl')['namespace']):continue
            selection=self.value(button,'ButtonControl','<SelectionControl>k__BackingField')
            if not selection or self.r.class_name(self.r.u64(selection))!=(self.shape('ToggleButtonSelectionControl')['name'],self.shape('ToggleButtonSelectionControl')['namespace']):continue
            invoke=self.value(selection,'ToggleButtonSelectionControl','onValueChanged')
            if not invoke:continue
            item_control=self.value(invoke,'Delegate','m_target')
            shape=self.shape('ButtonCollectionItemControl`2')
            if not item_control or self.r.class_name(self.r.u64(item_control))!=(shape['name'],shape['namespace']):continue
            self.delegate(invoke,item_control,navigation_source()['r7_dynamic_category']['selection_invoke_rva'])
            item=self.closed_values(item_control,'ButtonCollectionItemControl`2')
            if item('buttonControl')!=button or item('dataCache')!=target_data:continue
            item_callback=item('buttonCallback');closure=self.value(item_callback,'Delegate','m_target')
            self.delegate(item_callback,closure,navigation_source()['r7_dynamic_category']['radio_item_callback_rva'])
            captured=self.closed_values(closure,'<>c__DisplayClass23_0')
            radio=captured('<>4__this');value=self.closed_values(radio,'RadioButtonCollection`1');callback=value('onSelectionChanged')
            if captured('buttonCallback') or self.value(selection,'ToggleButtonSelectionControl','group'):
                raise MapReadPending('current dynamic category has another selection owner or extra callback')
            if self.value(selection,'ToggleButtonSelectionControl','<IsSelected>k__BackingField'):
                raise MapReadPending('current default category toggle is already selected before model update')
            if not callback:continue
            owner=self.value(callback,'Delegate','m_target')
            if not owner or self.r.class_name(self.r.u64(owner))!=(self.shape('DynamicCategoryGroupControl')['name'],self.shape('DynamicCategoryGroupControl')['namespace']):continue
            if self.value(owner,'DynamicCategoryGroupControl','uiSlot')!=sub_slot:continue
            self.delegate(callback,owner,navigation_source()['r7_dynamic_category']['selection_callback_rva'])
            if self.value(owner,'DynamicCategoryGroupControl','radioCollection')!=radio:
                raise MapReadPending('current dynamic category radio owner differs')
            rendered_data=dict(self.reference_pairs(value('buttonDataDic')))
            if rendered_data.get(button)!=target_data:continue
            actual=self.s.refs(self.value(owner,'DynamicCategoryGroupControl','dataList'),element='DynamicCategoryItemData',limit=128)
            if actual!=items:raise MapReadPending('current dynamic category model does not own inventory data')
            selected=value('selectedData');selected_index=self.value(owner,'DynamicCategoryGroupControl','lastSelectedIndex')
            if not 0<=selected_index<len(items) or items[selected_index]!=selected or current not in tree[selected]:
                raise MapReadPending('current dynamic category selection synchronizing')
            dropdown=self.value(owner,'DynamicCategoryGroupControl','dropDownContext')
            if dropdown and not self.value(dropdown,'DropDownContext','<IsDisposed>k__BackingField'):
                raise MapReadPending('current dynamic category dropdown must close before reset')
            if value('allowAllOff') or self.value(selection,'ToggleButtonSelectionControl','<OnClick>k__BackingField'):
                raise MapReadPending('current dynamic category has unsupported additional callback')
            raw=self.s.slot_raw(self.value(button,'ButtonControl','uiSlot'))
            if self.registered_button(raw)!=(button,registered,input_group):raise MapReadPending('current dynamic category input changed')
            geometry=self.s.read_control_geometry(button,ui,frame)
            geometry.update(index=items.index(target_data),window_id=ui,window_kind='native-ui-root',
                event_kind='native-dynamic-radio-selection',model_event_id=invoke,button_pointer=button)
            semantics=dict(ui_kind='quest_fake_item_inventory',ui_pointer=ui,fake_item_id=item_id,
                category_component_pointer=component,category_context_pointer=context_pointer,category_control_pointer=owner,
                category_data_pointer=target_data,category_id=target,radio_collection_pointer=radio,
                selection_invoke_pointer=invoke,item_control_pointer=item_control,item_callback_pointer=item_callback,radio_closure_pointer=closure,
                callback_pointer=callback,callback_method_rva=navigation_source()['r7_dynamic_category']['selection_callback_rva'],
                registered_input_pointer=registered,input_registry_pointer=input_group,selected_data_pointer=selected,
                current_selected_unique_id=current,default_unique_id=default,category_items=items,navigation_source_hash=navigation_source()['source_hash'])
            matches.append(dict(stage='inventory_category',candidate=geometry,pointer_state=self.s.current_pointer_state(),missing=[],
                pointer_semantics=semantics,navigation=dict(kind='dynamic_category',current_fields=fields,
                    expected_fields=dict(inventory_ui_pointer=ui,inventory_category=fields['inventory_category'],
                        inventory_dynamic_selected=default,inventory_dynamic_default=default),
                    receipt_state=dict(kind='dynamic_category',from_selected=current,target_unique_id=default,
                        context_pointer=context_pointer,items=items,source_hash=navigation_source()['source_hash']),
                    source_refs=[SOURCE+'#'+navigation_source()['source_hash']])) )
        if len(matches)!=1:raise MapReadPending('current default subcategory has no unique registered owned control')
        return matches[0]

    def filter(self,ui,panel,fields,frame,item_id,*,allow_open):
        from .story_ui_observer import XForestView
        from .story_ui_actions import planner_receipt
        component=self.value(ui,'EquipmentInventoryUIInstance','itemDataFilterContextComponent')
        context=self.value(component,'CommonItemDataFilterContextComponent','context')
        if not context:raise MapReadPending('current inventory filter context unavailable')
        self.fields(context,'Context')
        popups=self.s.current_instances('CommonFilterUIPopupInstance',source_shape=self.shape('CommonFilterUIPopupInstance'))
        if len(popups)>1:raise MapReadPending('current filter popup ambiguous')
        fields.update(inventory_filter_context=context,inventory_filter_visible=bool(popups))
        common=dict(ui_kind='quest_fake_item_inventory',fake_item_id=item_id,inventory_ui_pointer=ui,
            filter_context_pointer=context,filter_component_pointer=component,navigation_source_hash=navigation_source()['source_hash'])
        if popups:
            popup=popups[0]
            if self.value(popup,'CommonFilterUIPopupInstance','context')!=context:
                raise MapReadPending('current filter popup belongs to another context')
            if self.value(popup,'MMMLPopupUIInstanceBase','resultIsSet'):
                raise MapReadPending('current filter popup result already set')
            handle=self.value(popup,'MMMLPopupUIInstanceBase','handle')
            hf=self.s.object_fields(handle,'MMMLUIHandle')
            forest,ident=struct.unpack('<Qi',self.s.watch(handle+hf['<ViewModelRoot>k__BackingField'],12))
            values=XForestView(self.s,forest).attributes(ident,{'isDefaultState','clearClicked','confirmClicked'})
            default=values.get('isDefaultState')
            if type(default) is not bool:raise MapReadPending('current filter default state model unavailable')
            action='confirm' if default else 'clear'
            event=values.get(navigation_source()['mmml']['callbacks'][action])
            if not isinstance(event,dict) or event.get('event_present') is not True:
                raise MapReadPending('current filter callback unavailable')
            layout=self.s.story_layout(handle);layout.update(ui_pointer=popup,ui_type='CommonFilterUIPopupInstance')
            self.s.bind_runtime_callbacks(layout,handle=handle)
            matches=[c for c in layout['candidates'] if c.get('model_event_id')==event['event_id']
                and c.get('tag')=='Button.Base' and c.get('button_pointer') and (c.get('runtime_attributes') or {}).get('disabled') is not True]
            if len(matches)!=1:raise MapReadPending('current exact filter action control is not unique')
            candidate=deepcopy(matches[0]);candidate.update(self.s.read_control_geometry(candidate['button_pointer'],popup,frame))
            candidate.update(event_kind='mmml-event',window_kind='mmml-window',window_id=layout['window_id'])
            fields.update(inventory_filter_default=default,inventory_filter_popup=popup)
            expected=dict(inventory_filter_context=context,inventory_filter_visible=False) if default else dict(inventory_filter_context=context,inventory_filter_popup=popup,inventory_filter_default=True)
            common.update(ui_pointer=popup,handle=handle,filter_action=action,event_id=event['event_id'],is_default_state=default)
            return dict(present=True,ui_pointer=popup,handle=handle,window_id=layout['window_id'],stage='inventory_filter',
                candidate=candidate,pointer_state=self.s.current_pointer_state(),pointer_semantics=common,missing=[],
                navigation=dict(kind='filter_'+action,current_fields=fields,expected_fields=expected,
                    source_refs=[SOURCE+'#'+navigation_source()['source_hash']]))
        if not allow_open:return None
        # A confirmed return from this same filter context with no matching
        # model must not endlessly reopen the popup. Preserve that receipt and
        # the exact-item goal until new inventory/native evidence exists.
        expected=planner_receipt(frame).get('expected_effect') or {}
        wanted=expected.get('fields') or {}
        if wanted.get('inventory_filter_context')==context and wanted.get('inventory_filter_visible') is False:
            return None
        category=self.value(panel,'InventoryPanelUI','inventoryCategoryUIControl')
        control=self.value(category,'InventoryCategoryUIControl','CommonFilterButtonUIControl')
        slot=self.value(control,'CommonFilterButtonUIControl','uiSlot')
        button_slot=self.value(slot,'CommonFilterButtonUISlot','<FilterButton>k__BackingField')
        button,registered,input_group=self.registered_button(self.s.slot_raw(button_slot))
        selection=self.value(button,'ButtonControl','<SelectionControl>k__BackingField')
        if any(self.value(selection,'DefaultButtonSelectionControl',k) for k in ('ClickedWithTouchDeviceType','ClickedWithMouseButtonType','<OnClick>k__BackingField')):
            raise MapReadPending('current inventory filter button has additional handlers')
        callback=self.value(selection,'DefaultButtonSelectionControl','ClickedWithTouchInputMethod')
        closure=self.value(callback,'Delegate','m_target');self.delegate(callback,closure,0x994c370)
        if self.value(closure,'<>c__DisplayClass2_0','uiSlot')!=slot:
            raise MapReadPending('current inventory filter callback does not own slot')
        candidate=self.s.read_control_geometry(button,ui,frame)
        candidate.update(index=0,window_id=ui,window_kind='native-ui-root',event_kind='native-inventory-filter-open',model_event_id=callback,button_pointer=button)
        common.update(ui_pointer=ui,filter_action='open',callback_pointer=callback,closure_pointer=closure,
            registered_input_pointer=registered,input_registry_pointer=input_group)
        return dict(stage='inventory_filter',candidate=candidate,pointer_state=self.s.current_pointer_state(),pointer_semantics=common,missing=[],
            navigation=dict(kind='filter_open',current_fields=fields,expected_fields=dict(inventory_filter_context=context,inventory_filter_visible=True),
                source_refs=[SOURCE+'#'+navigation_source()['source_hash']]))

    def category(self,ui,panel,component,data,fields,frame,item_id):
        target=self.shape('InventoryMainCategory')['enum_constants']['All']
        if fields['inventory_category']==target:return None
        rows=self.s.dictionary_values(self.value(data,'StaticCategoryGroupData','StaticCategoryItemList'),limit=128)
        choices=[]
        for key,row in rows:
            unique=self.value(row,'StaticCategoryItemData','UniqueId')
            if key!=unique:raise MapReadPending('current category dictionary ID differs from data')
            if unique==target and all(self.value(row,'StaticCategoryItemData',k) for k in ('IsEnabled','IsActive')):choices.append(row)
        if len(choices)!=1:raise MapReadPending('current source All category not uniquely enabled')
        row=choices[0];index=self.value(row,'StaticCategoryItemData','CategoryIndex')
        control=self.value(panel,'InventoryPanelUI','inventoryCategoryUIControl')
        slot=self.value(control,'InventoryCategoryUIControl','uiSlot')
        static_slot=self.value(slot,'InventoryCategoryUISlot','<StaticCategoryGroupSlot>k__BackingField')
        slots=self.s.refs(self.value(static_slot,'StaticCategoryGroupSlot','<CategoryGroup>k__BackingField'),element='ButtonSlot',limit=128)
        if not 0<=index<len(slots):raise MapReadPending('current source category index outside slot array')
        button,registered,input_group=self.registered_button(slots[index])
        selection=self.value(button,'ButtonControl','<SelectionControl>k__BackingField')
        group=self.value(selection,'ToggleButtonSelectionControl','group')
        callback=self.value(group,'RadioButtonGroup','onSelectionChanged')
        owner=self.value(callback,'Delegate','m_target');self.delegate(callback,owner,0x9a14910)
        if self.value(owner,'StaticCategoryGroupControl','uiSlot')!=static_slot or self.value(owner,'StaticCategoryGroupControl','radioButtonGroup')!=group:
            raise MapReadPending('current category callback does not own inventory slot')
        if not self.value(owner,'StaticCategoryGroupControl','isInitialize') or self.value(owner,'StaticCategoryGroupControl','alwaysInvoke'):
            raise MapReadPending('current category has unsupported initialization/additional invocation')
        if self.value(owner,'StaticCategoryGroupControl','onSelectionChanged') or self.value(selection,'ToggleButtonSelectionControl','<OnClick>k__BackingField'):
            raise MapReadPending('current category has additional callback')
        ids=self.int_array(self.value(owner,'StaticCategoryGroupControl','uniqueIds'))
        selections=self.s.refs(self.value(group,'RadioButtonGroup','selectionControls'),element='IButtonSelectionControl',limit=128)
        if index>=len(ids) or ids[index]!=target or index>=len(selections) or selections[index]!=selection:
            raise MapReadPending('current category radio index/data binding changed')
        if dict(self.reference_pairs(self.value(group,'RadioButtonGroup','selectionControlMap'))).get(selection)!=button:
            raise MapReadPending('current category radio control map changed')
        offset=self.fields(owner,'StaticCategoryGroupControl')['groupId']
        group_id=self.s.watch(owner+offset,16)
        if group_id!=self.static('InventoryCategoryUIComponent','MainCategoryGroupId',16):
            raise MapReadPending('current category message group is not inventory main category')
        geometry=self.s.read_control_geometry(button,ui,frame)
        geometry.update(index=index,window_id=ui,window_kind='native-ui-root',event_kind='native-radio-selection',
            model_event_id=callback,button_pointer=button)
        return dict(stage='inventory_category',candidate=geometry,pointer_state=self.s.current_pointer_state(),missing=[],
            pointer_semantics=dict(ui_kind='quest_fake_item_inventory',ui_pointer=ui,fake_item_id=item_id,
                category_data_pointer=row,category_component_pointer=component,category_control_pointer=owner,
                category_index=index,category_id=target,callback_pointer=callback,callback_method_rva=0x9a14910,
                input_registry_pointer=input_group,registered_input_pointer=registered,group_id_hex=group_id.hex(),navigation_source_hash=navigation_source()['source_hash']),
            navigation=dict(kind='category',current_fields=fields,
                expected_fields=dict(inventory_ui_pointer=ui,inventory_category=target,inventory_selected_category=target),
                source_refs=[SOURCE+'#'+navigation_source()['source_hash']]))


def navigate_inventory(view,base,ui,panel,icons,frame,item_id):
    """Decorate current inventory observation, preserving existing item clicks."""
    nav=InventoryNavigationView(view.s);has_filter=False
    try:
        nav.verify_methods();component,data,fields=nav.state(ui,icons)
        dynamic=nav.dynamic_state(component,fields)
        base['navigation']=dict(kind='observation',current_fields=fields,source_refs=[SOURCE+'#'+navigation_source()['source_hash']])
        # A currently open owned filter takes priority over controls underneath.
        has_filter=bool(nav.value(ui,'EquipmentInventoryUIInstance','itemDataFilterContextComponent')) and hasattr(view.s,'current_instances')
        if has_filter:
            filtering=nav.filter(ui,panel,fields,frame,item_id,allow_open=False)
            if filtering:base.update(filtering);return base
        # Existing exact data takes priority over changing categories/filters.
        exact=[p for p in icons if nav.exact_item(p,item_id)]
        if exact:return navigate_scroll(nav,base,ui,panel,exact,frame,item_id)
        if base.get('candidate') is not None:return base
        result=nav.category(ui,panel,component,data,fields,frame,item_id)
        if result:base.update(result)
        else:
            reset=nav.dynamic_category(ui,panel,component,fields,frame,item_id,context=dynamic)
            if reset:base.update(reset);return base
            filtering=nav.filter(ui,panel,fields,frame,item_id,allow_open=True) if has_filter else None
            if filtering:base.update(filtering)
            else:base['missing']=['current_exact_fake_item_filtered_model_absent']
    except (ValueError,RuntimeError,KeyError) as exc:
        # A currently rendered exact row retains its established source path.
        # Missing navigation evidence never supplies a made-up fallback input.
        base['navigation_missing']=str(exc)
        if has_filter:
            base['candidate']=None; base['missing']=['current_owned_inventory_navigation_required: '+str(exc)]
    return base


def navigate_scroll(nav,base,ui,panel,exact,frame,item_id):
    from .map_quest_accept_observer import map_scroll_session, _intersection
    if len(exact)!=1:raise MapReadPending('current requested fake item data is not unique')
    scroll=nav.value(panel,'InventoryPanelUI','inventoryScrollListControl')
    collection=nav.value(scroll,'InventoryScrollListControl','collectionControl')
    alignment=nav.value(collection,'CollectionControl','alignmentModule')
    proxy=map_scroll_session(nav.s);choices=[]
    for element_collection,entry in nav.reference_pairs(nav.value(alignment,'CollectionControlItemAlignmentModule','collectionEntries')):
        layout=proxy.current_layout(collection,element_collection)
        target=[r for r in layout['rows'] if r['data_pointer']==exact[0]]
        if not target:continue
        if len(target)!=1:raise MapReadPending('current fake item repeated in layout model')
        table=nav.value(entry,'CollectionControlAlignmentModuleCollectionEntry','modelToControlTable')
        current={r['model_pointer']:r for r in layout['rows']};visible=[];sample=None
        for model,entry_value in nav.reference_pairs(nav.value(table,'CollectionControlModelToControlTable','table')):
            row=current.get(model)
            if not row or not row['active']:continue
            wrapper=nav.value(entry_value,'Entry','ControlWrapper')
            icon=nav.value(wrapper,'CollectionItemControlWrapper','control')
            if nav.value(icon,'InventoryItemIconControl','data')!=row['data_pointer']:
                raise MapReadPending('current scroll model and rendered icon differ')
            button=nav.value(icon,'InventoryItemIconControl','buttonControl')
            try:geometry=nav.s.read_control_geometry(button,ui,frame)
            except MapReadPending:continue
            visible.append(dict(model_pointer=model,data_pointer=row['data_pointer'],center=row['center'],rect_client=geometry['rect_client']))
            sample=geometry
        if not visible or sample is None:raise MapReadPending('current inventory scroll has no visible source icon')
        binding=proxy.collection_binding(collection)
        if binding.get('scroll_direction')!=0:
            raise MapReadPending('current inventory horizontal receiver requires its own source axis consumer')
        physical=frame['physical_client_rect'];size=(physical[2]-physical[0],physical[3]-physical[1])
        viewport=proxy._native_ui.overlay_rect(binding['viewport_rect'],sample['canvas'],size)
        safe=frame['current_context']['safe_rect']
        area=_intersection(viewport['rect_client'],[safe[0],safe[1],safe[2]-safe[0],safe[3]-safe[1]])
        if not area or min(area[2:])<4:raise MapReadPending('current inventory scroll viewport clipped')
        visible=[r for r in visible if _intersection(r['rect_client'],area)]
        if not visible:raise MapReadPending('current inventory model has no actually visible controls')
        # Pooled controls can exist just outside the visible viewport. Their
        # existence is not permission to click outside this inventory list.
        if base.get('candidate') is not None:
            clipped=_intersection(base['candidate']['rect_client'],area)
            if not clipped or min(clipped[2:])<4:base['candidate']=None
            else:base['candidate']['rect_client']=clipped
        axis=proxy.layout_screen_axis(layout);centers=[r['center'][1]*axis for r in visible]
        wanted=target[0]['center'][1]*axis
        observation=dict(model_revision=layout['model_revision'],layout_revision=digest(dict(rule=layout['layout_rule'],pivot=layout['pivot'],rows=[{k:r[k] for k in ('model_pointer','data_pointer','center','size')} for r in layout['rows']])),
            visible=visible,viewport_rect=area,axis=axis,target_model=target[0]['model_pointer'],target_center=target[0]['center'])
        fields=base['navigation']['current_fields'];fields['inventory_scroll_revision']=digest(observation)
        fields['inventory_scroll_progress']=scroll_progress(frame,observation,target_visible=base.get('candidate') is not None)
        if base.get('candidate') is not None:return base
        if wanted<min(centers)-1e-4:delta=120
        elif wanted>max(centers)+1e-4:delta=-120
        else:raise MapReadPending('current inventory target requires a different layout axis or render update')
        candidate=dict(rect_client=area,native_geometry_proof=sample['native_geometry_proof'],control_pointer=binding['control'],
            root_object_pointer=binding['root_object'],handler_pointer=binding['handler'],target_native_game_object=binding['target'],
            ui_pointer=ui,window_id=ui,window_kind='native-ui-root',event_kind='registered-scroll-handler',model_event_id=0,
            index=target[0]['index'],delta=delta,registration=binding['registration'])
        state=nav.s.current_pointer_state();proof=proxy.receiver_proof(binding,viewport,state,delta,frame)
        if proof:candidate['scroll_receiver_proof']=proof
        choices.append(dict(stage='inventory_scroll',candidate=candidate,pointer_state=state,missing=[],
            pointer_semantics=dict(ui_kind='quest_fake_item_inventory',ui_pointer=ui,fake_item_id=item_id,
                action_kind='pointer_scroll',delta=delta,collection_pointer=collection,element_collection_pointer=element_collection,
                target_data_pointer=exact[0],scroll_observation=observation,navigation_source_hash=navigation_source()['source_hash']),
            navigation=dict(kind='scroll',current_fields=fields,expected_fields={'inventory_scroll_progress':True},receipt_state=dict(kind='scroll',observation=observation,delta=delta),
                source_refs=[SOURCE+'#'+navigation_source()['source_hash'],'data/r6-map-card/source.json'])))
    if len(choices)!=1:raise MapReadPending('current inventory exact item layout is not unique')
    base.update(choices[0]);return base


def scroll_progress(frame,current,*,target_visible=False):
    """A later same-model movement, never an arbitrary digest change."""
    from .story_ui_actions import planner_receipt
    receipt=planner_receipt(frame);expected=receipt.get('expected_effect') or {}
    saved=expected.get('inventory_navigation') or {}
    before=saved.get('observation') or {};delta=saved.get('delta')
    if expected.get('phase')!='quest_item_ui' or delta not in (-120,120):return False
    if any(before.get(k)!=current.get(k) for k in ('model_revision','layout_revision','target_model','axis')):return False
    if target_visible:return True
    old={x['model_pointer']:x for x in before.get('visible',[])}
    shifts=[x['rect_client'][1]-old[x['model_pointer']]['rect_client'][1] for x in current.get('visible',[]) if x['model_pointer'] in old]
    return bool(shifts) and all(abs(x)>=0.5 and x*delta>0 for x in shifts)
