"""Actual read adapters assembled at meaningful graph boundaries.

Default construction never opens game memory or sends input. Missing current
identity/layout is a structured gap, not an empty successful game state.
"""
from dataclasses import asdict
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json
import re
import uuid
from time import perf_counter_ns
from .arbeit_runtime import normalize_quest_read


def normalized(value):
    return "".join(re.sub(r"<[^>]*>", "", str(value)).split())


def current_pointer_probe_receipt(pending):
    """Expose one actually sent probe row without manufacturing an input event."""
    if not isinstance(pending,dict):return None
    sent=pending.get('input_receipt') or {}
    if sent.get('status')!='sent_unconfirmed':return None
    rows=sent.get('receipts') or []
    if not isinstance(rows,list) or len(rows)!=1 or not isinstance(rows[0],dict):return None
    row=rows[0]
    if (row.get('status')!='sent_unconfirmed' or (row.get('command') or {}).get('kind')!='pointer_probe'
            or not pending.get('operation_id') or row.get('operation_id')!=pending['operation_id']+':0'):
        return None
    return dict(deepcopy(row),parent_operation_id=pending['operation_id'])


def inventory_from_read(envelope, character_id, session_id):
    body = envelope.get("body")
    raw_rows = body.get("items") if isinstance(body, dict) else body
    if not envelope.get("ok") or not isinstance(raw_rows, list):
        return None
    rows = []
    for row in raw_rows:
        if (not isinstance(row, dict) or not isinstance(row.get("DisplayName"), str) or not isinstance(row.get("Category"), str) or
                row.get("Location") not in ("inventory", "account_storage", "character_storage") or
                type(row.get("Count")) is not int or row["Count"] < 0 or type(row.get("IsLocked")) is not bool):
            return None
        rows.append({"name": row["DisplayName"], "category": row["Category"], "quantity": row["Count"],
            "locked_quantity": row["Count"] if row["IsLocked"] else 0,
            "scope": {"inventory": "bag", "account_storage": "account_storage", "character_storage": "character_storage"}[row["Location"]]})
    return {"source": "get_items", "observed_at": envelope["observed_at"], "character_id": character_id,
        "session_id": session_id, "complete": True, "items": rows, "response_sha256": envelope.get("response_sha256"),
        "scope": "official_consumable_items_only", "no_stable_stack_id": True}


def bind_current_objectives(graph, envelope, **kwargs):
    from .objective_binding import bind_objectives
    return bind_objectives(graph, envelope, **kwargs)


class GameObserver:
    def __init__(self, graph, cli, window_adapter, *, selected_quest=None, identity_provider=None, ui_provider=None, map_provider=None, quest_provider=None, life_provider=None, processing_provider=None, craft_provider=None, supply_selector=None, bag_provider=None, recovery_reader=None, pending_reader=None, material_context_reader=None, reservation_reader=None, objective_inventory_provider=None, world_target_provider=None, world_ui_provider=None, group_npc_provider=None, direct_dialogue_provider=None, story_provider=None, map_quest_card_provider=None, input_owner_provider=None, context_reader=None, ocr=None, initial_sequence=0):
        self.graph, self.cli, self.window = graph, cli, window_adapter
        self.selected_quest = dict(selected_quest or {})
        self.identity_provider, self.ui_provider, self.map_provider, self.ocr = identity_provider, ui_provider, map_provider, ocr
        self.context_reader = context_reader
        self.quest_provider, self.life_provider = quest_provider, life_provider
        self.processing_provider, self.bag_provider = processing_provider, bag_provider
        self.recovery_reader = recovery_reader
        self.pending_reader, self.input_owner_provider = pending_reader, input_owner_provider
        self.material_context_reader = material_context_reader
        self.reservation_reader = reservation_reader
        self.objective_inventory_provider = objective_inventory_provider
        self.world_target_provider, self.world_ui_provider = world_target_provider, world_ui_provider
        self.group_npc_provider = group_npc_provider
        self.direct_dialogue_provider = direct_dialogue_provider
        self.story_provider = story_provider
        self.map_quest_card_provider = map_quest_card_provider
        self.craft_provider = craft_provider
        self.supply_selector = supply_selector
        self.sequence = initial_sequence
        self.observation_clock_id = uuid.uuid4().hex
        self.session_id = uuid.uuid4().hex
        self.last_input_context = None
        self.last_frame = None

    def _observe(self, name, report, callback, *args, **kwargs):
        try:
            value = callback(*args, **kwargs)
            if not isinstance(value, dict):
                raise ValueError('provider_response_not_object')
            return value
        except (OSError, ValueError, RuntimeError, KeyError, TypeError, AttributeError) as exc:
            reason = name + ':' + type(exc).__name__ + ':' + str(exc)
            report['missing'].append(reason)
            report.setdefault('provider_errors', []).append({'provider':name,'reason':reason})
            return {'ok':False,'source':name,'reason':reason,'missing':[reason]}

    def read(self, node):
        read_started_ns = perf_counter_ns()
        self.sequence += 1
        self.last_frame = None
        captured_frame = None
        snapshot = {"ok": False, "mode": "live", "sequence": self.sequence,
            "observation_id": f"{self.session_id}:{self.sequence}", "session_id": self.session_id,
            "observed_at": datetime.now(timezone.utc).isoformat(), "observation_source": "actual_official_cli_and_visible_client",
            "fresh": True, "missing": [], "action_proposals": {}}
        preflight = self.window.preflight()
        snapshot["window_preflight"] = preflight
        if not preflight.get("ok"):
            return snapshot | {"reason": "window_observation_unavailable", "missing": preflight.get("blockers", [])}
        from .live_io import WindowSnapshot
        window = WindowSnapshot(**preflight["selected"])
        snapshot['process_started_at'] = window.process_started_at
        if self.window.bound is None:
            self.window.bind(window)
        elif self.window.bound.identity != window.identity or self.window.bound.client_rect != window.client_rect:
            return snapshot | {"reason": "window_changed_rebind_required", "missing": ["new_current_window_binding"]}
        if self.identity_provider is None:
            return snapshot | {"reason": "current_character_provider_unbound", "missing": ["verified_character_id_and_session"]}
        identity = self._observe('current_identity', snapshot, self.identity_provider, window)
        if not identity.get("ok") or not identity.get("character_id"):
            return snapshot | {"reason": identity.get("reason", "character_observation_unavailable"), "identity": identity}
        snapshot["character_id"] = identity["character_id"]
        if self.selected_quest and str(snapshot["character_id"]) != str(self.selected_quest.get("character_id")):
            return snapshot | {"reason":"selected_character_changed", "missing":["selected_character_changed"]}
        self.session_id = hashlib.sha256(json.dumps([window.pid, window.process_started_at, identity["character_id"]]).encode()).hexdigest()
        snapshot["session_id"] = self.session_id
        snapshot["observation_id"] = f"{self.session_id}:{self.sequence}"
        snapshot["identity"] = dict(identity, confirmed=True, session_id=self.session_id, character_id=snapshot["character_id"])
        active = identity.get('current_autoplay')
        if isinstance(active, dict):
            # session_id is our observation scope, not a game-returned fact.
            # The initial identity read precedes derivation of the stable
            # process/character session. Preserve its provisional reader scope.
            snapshot['identity']['current_autoplay'] = dict(active, session_id=self.session_id,
                reader_session_id=active.get('session_id'))
        snapshot['input_context'] = dict(window=window, character_id=snapshot['character_id'], session_id=self.session_id,
            window_signature=json.dumps(window.identity), observed_process_started_at=identity.get('process_started_at'),
            source_revision=self.graph['source_fingerprint'], observed_at=snapshot['observed_at'])
        snapshot['pending_input'] = self.pending_reader() if self.pending_reader else None
        if self.material_context_reader:
            snapshot.update(self.material_context_reader())
        if self.input_owner_provider:
            snapshot['input_owner'] = self._observe('input_owner', snapshot, self.input_owner_provider,
                character_id=snapshot['character_id'], session_id=self.session_id)
        if self.quest_provider is not None:
            quest_state = self._observe('quest_state', snapshot, self.quest_provider, window=window, graph=self.graph, node=node, identity=identity, session_id=self.session_id)
            snapshot["quest_state_read"] = quest_state
            snapshot['current_quest'] = quest_state.get('current_quest')
            if quest_state.get("ok"):
                for key in ("state_proofs", "step_effects", "offers", "objective_programs", "action_requests"):
                    snapshot[key] = quest_state.get(key, [])
                snapshot["runtime_descriptors"] = quest_state.get("current_descriptors", [])
                snapshot["runtime_trait_descriptors"] = quest_state.get("current_trait_descriptors", [])
            snapshot["missing"].extend(quest_state.get("missing", []))
        if self.selected_quest:
            from .selected_quest import selection_error
            problem = selection_error(self.selected_quest, snapshot)
            if problem:
                return snapshot | {"reason":problem,"missing":[problem]}
        from .operation_binding import bind_operation
        dispatch=bind_operation(node,snapshot)
        snapshot['operation_binding']=dispatch['binding']
        snapshot['operation_binding_status']=dispatch['status']
        snapshot['missing'].extend(dispatch['missing'])
        node=dispatch['node']
        # Invalid typed input cannot activate a catalog-based dedicated provider.
        # The planner preserves the original graph node and reports this gap.
        if dispatch['status']=='invalid':node=dict(node,op='objective.unresolved')
        quest_read = self._observe('official_quests', snapshot, self.cli.query, "get_quests")
        snapshot["quest_read"] = quest_read
        if not quest_read.get("ok"):
            snapshot["missing"].append("official_quest_read_unavailable:" + str(quest_read.get("error")))
        if node["op"] in ("inventory.hold", "life.craft", "life.alter", "life.gather", "objective.deliver"):
            raw = self._observe('official_inventory', snapshot, self.cli.query, "get_items")
            snapshot["inventory"] = inventory_from_read(raw, snapshot["character_id"], self.session_id)
            snapshot["inventory_read"] = raw
            if snapshot["inventory"] is None:
                snapshot["missing"].append("fresh_inventory_response")
        if self.objective_inventory_provider is not None:
            snapshot['objective_inventory']=self._observe('objective_inventory',snapshot,
                self.objective_inventory_provider,snapshot=snapshot,node=node)
            snapshot['missing'].extend(snapshot['objective_inventory'].get('missing',[]))
        objective_binding = bind_current_objectives(self.graph, quest_read, character_id=snapshot["character_id"],
            session_id=self.session_id, observation_id=snapshot["observation_id"], inventory=snapshot.get("inventory"),
            quest_state=snapshot.get('quest_state_read'), now=datetime.now(timezone.utc).isoformat())
        snapshot["missing"].extend(objective_binding.pop("missing"))
        snapshot.update(objective_binding)
        from .goal_reservations import reconcile_reservations
        from .arbeit_operations import _fresh
        try:
            stored=self.reservation_reader() if self.reservation_reader else []
            snapshot['goal_reservations'],snapshot['goal_reservation_gaps']=reconcile_reservations(
                self.graph,stored or [],snapshot,(snapshot.get('current_quest') or {}).get('accept_time'),_fresh)
        except (OSError,ValueError,RuntimeError,KeyError,TypeError,AttributeError) as exc:
            return snapshot | {'reason':'current_goal_reservation_reconciliation_unavailable','missing':snapshot['missing']+[str(exc)]}
        if 'material_requirement' in node.get('params',{}):
            from .material_supply import make_material_hold_request
            request=make_material_hold_request(node,snapshot)
            snapshot['material_hold_requests']=[request]
            snapshot['missing'].extend(request.get('missing',[]))
        if node['op']=='inventory.hold' and self.supply_selector is not None:
            snapshot['supply_selection']=self.supply_selector.choose(node,snapshot,pending=snapshot.get('pending_input'))
            snapshot['supply_ui_cleanup_required']='owned_supply_ui_cleanup_required' in snapshot['supply_selection'].get('missing',[])
        visuals = self._observe('capture_and_visuals', snapshot, self._read_visuals, node, window, snapshot)
        captured_frame, image = visuals.get('frame'), visuals.get('image')
        acquisition=self._read_quest_item_supply(node,window,snapshot,captured_frame,image)
        if acquisition is not None:
            captured_frame=acquisition['frame']
        map_node=acquisition['observation_node'] if acquisition is not None else node
        map_snapshot=acquisition['snapshot'] if acquisition is not None else snapshot
        snapshot['input_context']['frame_id'] = (captured_frame or {}).get('frame_id')
        if self.craft_provider is not None and self._supply_provider_needed(node,snapshot,'life.craft','native_craft'):
            crafting = self._observe('craft', snapshot, self.craft_provider,
                window=window, node=node, snapshot=snapshot, image=image, frame=captured_frame)
            snapshot['craft_read'] = crafting
            for key in ('result_cleanup_evidence','supply_evidence','material_return_ui'):
                if crafting.get(key):snapshot[key]=crafting[key]
            if crafting.get('crafting'):
                snapshot['crafting'] = crafting['crafting']
                if crafting['crafting'].get('return_ui'):
                    snapshot['material_return_ui']=crafting['crafting']['return_ui']
            snapshot['missing'].extend(crafting.get('missing', []))
            self._record_supply_feedback(node,snapshot)
        if self.processing_provider is not None and self._supply_provider_needed(node,snapshot,'life.alter','native_alter'):
            processing = self._observe('processing', snapshot, self.processing_provider, node=node, snapshot=snapshot, image=image, frame=captured_frame)
            snapshot["processing_read"] = processing
            if processing.get("native_processing"):
                snapshot["native_processing"] = processing["native_processing"]
                snapshot.setdefault('ui_effect_proofs',[]).extend(
                    (processing['native_processing'].get('pointer_effect') or {}).get('effect_proofs',[]))
                if processing['native_processing'].get('return_ui'):
                    snapshot['material_return_ui']=processing['native_processing']['return_ui']
            if processing.get("inventory"):
                snapshot["inventory"] = processing["inventory"]
            snapshot["missing"].extend(processing.get("missing", []))
        if snapshot.get('action_requests') or map_node["op"] in ("move.target", "objective.move", "objective.dungeon_clear", "dialogue.accept", "dialogue.talk", "dialogue.report", "objective.deliver", "objective.inspect", "objective.read_story", "objective.unresolved"):
            if self.map_provider is None:
                snapshot["missing"].append("current_map_observer_binding")
            else:
                normalized=map_node.get('params',{}).get('typed_operands') or {}
                map_options={'region_requests':[deepcopy(normalized)]} if normalized.get('ready') and normalized.get('operation')=='objective.move' else {}
                snapshot["map"] = self._observe('map', snapshot, self.map_provider, window=window, node=map_node, current_ui=snapshot.get("current_ui"),
                    character_id=snapshot["character_id"], session_id=self.session_id, layout=snapshot.get("layout"),
                    process_started_at=identity.get("process_started_at"),**map_options)
                map_snapshot['map']=snapshot['map']
                if (self.group_npc_provider is not None and normalized.get('ready')
                        and 'npc_group_tag' in normalized.get('operands',{}) and captured_frame
                        and snapshot['map'].get('ok')):
                    targets=self._observe('projected_group_npc_targets',snapshot,self.group_npc_provider,
                        window=window,node=map_node,snapshot=map_snapshot,frame=captured_frame,image=image)
                    snapshot['projected_group_npc_read']=targets
                    for key,default in (('current_group_npc_targets',[]),('current_group_npc_observation',None)):
                        snapshot[key]=targets.get(key,default)
                        captured_frame[key]=deepcopy(snapshot[key])
                    snapshot['missing'].extend(targets.get('missing',[]))
                if (map_node.get('op')=='objective.inspect' and self.world_target_provider is not None and captured_frame
                        and not snapshot['action_proposals'].get(node['id']) and snapshot['map'].get('ok')):
                    targets=self._observe('projected_world_targets',snapshot,self.world_target_provider,
                        window=window,node=map_node,snapshot=map_snapshot,frame=captured_frame,image=image)
                    snapshot['projected_world_targets_read']=targets
                    snapshot['current_world_targets']=targets.get('current_world_targets',[])
                    snapshot['current_world_target_observation']=targets.get('current_world_target_observation')
                    captured_frame['current_world_targets']=deepcopy(snapshot['current_world_targets'])
                    captured_frame['current_world_target_observation']=deepcopy(snapshot['current_world_target_observation'])
                    snapshot['missing'].extend(targets.get('missing',[]))
        acceptance_card_node=(node.get('op')=='dialogue.accept' or
            (node.get('op')=='move.target' and node.get('params',{}).get('role')=='acceptance'))
        if self.map_quest_card_provider is not None and acceptance_card_node and captured_frame:
            cards=self._observe('map_quest_card',snapshot,self.map_quest_card_provider,
                window=window,node=node,snapshot=snapshot,frame=captured_frame,image=image)
            snapshot['map_quest_card_read']=cards
            snapshot['current_map_accept']=cards.get('current_map_accept',{})
            snapshot['map_quest_card_frame']=deepcopy(captured_frame)
            snapshot['missing'].extend(cards.get('missing',[]))
        snapshot["input_context"] = {"window": window, "character_id": snapshot["character_id"], "session_id": self.session_id,
            "window_signature": json.dumps(window.identity),
            "observed_process_started_at": identity.get("process_started_at"),
            "frame_id": (captured_frame or {}).get("frame_id"),
            "source_revision": self.graph["source_fingerprint"], "observed_at": snapshot["observed_at"],
            "safe_rect": (snapshot.get("current_ui") or {}).get("safe_rect")}
        self.last_input_context = snapshot["input_context"]
        if 'material_requirement' in node.get('params',{}):
            # Some suppliers refresh inventory at the current recipe boundary.
            # Rebind only current stock, keeping the saved source requirement.
            from .material_supply import make_material_hold_request
            request=make_material_hold_request(node,snapshot)
            snapshot['material_hold_requests']=[request]
            snapshot['missing'].extend(request.get('missing',[]))
        snapshot["read_started_at"] = snapshot["observed_at"]
        snapshot["observed_at"] = datetime.now(timezone.utc).isoformat()
        snapshot["read_completed_at"] = snapshot["observed_at"]
        snapshot['observation_clock'] = dict(source='GameObserver.read:perf_counter',
            producer_id=self.observation_clock_id, sequence=self.sequence,
            observation_id=snapshot['observation_id'], session_id=snapshot['session_id'],
            character_id=snapshot['character_id'], process_started_at=snapshot.get('process_started_at'),
            started_ns=read_started_ns, completed_ns=perf_counter_ns())
        # Individual CLI, identity, capture and target timestamps above remain
        # untouched. Wall time measures age; the scoped monotonic interval also
        # orders distinct transactions when Windows returns the same wall tick.
        snapshot["ok"] = True
        return snapshot

    def _record_supply_feedback(self,node,snapshot):
        """Feed this provider transaction back without choosing a new UI route."""
        if node.get('op')!='inventory.hold' or self.supply_selector is None:return
        selected=snapshot.get('supply_selection') or {};candidate=selected.get('candidate')
        if not candidate:return
        from .arbeit_operations import _fresh
        context_observer=getattr(self.supply_selector,'observe_context',None)
        if callable(context_observer):context_observer(node,snapshot,candidate)
        proofs=[p for p in snapshot.get('supply_evidence',[]) if isinstance(p,dict)
            and p.get('kind')==candidate.get('kind') and p.get('route_id')==candidate.get('route_id')
            and _fresh(p,snapshot)]
        if len(proofs)!=1:return
        proof=deepcopy(proofs[0]);proof['provider_evidence']=deepcopy(proofs[0])
        if candidate['kind']=='native_craft':
            scene=snapshot.get('crafting') or {}
            if (scene.get('confirmed') is not True or not _fresh(scene,snapshot) or scene.get('stage')!='detail'
                    or scene.get('selected_recipe_id')!=candidate.get('recipe_id')
                    or scene.get('input_owner')!=snapshot.get('input_owner')
                    or (snapshot.get('input_owner') or {}).get('continuous') is not True):return
            proof['current_detail']={k:deepcopy(scene.get(k)) for k in ('source','observed_at','confirmed','session_id',
                'character_id','recipe_id','selected_recipe_id','current_binding_kind','input_owner','window_signature','stage')}
            proof['current_detail']['pending_operation_id']=(snapshot.get('pending_input') or {}).get('operation_id')
            if proof.get('material_shortage') is True:proof['eligibility_kind']='material_shortage'
            elif scene.get('facility_level_low') is True or scene.get('decor_score_insufficient') is True:
                proof['eligibility_kind']='current_recipe_condition_unavailable'
            elif proof.get('eligible') is False:proof['eligibility_kind']='unresolved_current_condition'
            recipe=candidate.get('recipe');batches=(scene.get('quantity') or {}).get('value')
            if recipe and type(batches) is int and batches>0:
                try:
                    from .arbeit_crafting import craft_bag_materials
                    proof['current_bag_evidence']=craft_bag_materials(recipe,batches,snapshot,node_id=node['id'])
                except ValueError as exc:
                    reason=str(exc);proof['eligible']=False;proof['current_bag_missing']=reason
                    if reason.startswith(('craft_material_bag_shortage:','craft_material_goal_reserved_shortage:')):
                        proof.update(material_shortage=True,eligibility_kind='material_shortage')
                    else:proof['eligibility_kind']='unresolved_current_condition'
        try:
            self.supply_selector.observe(node,snapshot,candidate,eligible=proof.get('eligible'),evidence=proof)
            snapshot['supply_evidence']=[proof if p is proofs[0] else p for p in snapshot.get('supply_evidence',[])]
            snapshot.setdefault('supply_selection_feedback',[]).append(dict(kind=candidate['kind'],route_id=candidate['route_id'],
                eligible=proof['eligible'],eligibility_kind=proof.get('eligibility_kind'),source=proof['source'],
                observed_at=proof['observed_at'],applies_to_next_selection=True))
        except ValueError as exc:snapshot['missing'].append('supply_feedback:'+str(exc))

    def _read_visuals(self, node, window, snapshot):
        image, captured_frame = None, None
        if any(p is not None for p in (self.ui_provider,self.context_reader,self.life_provider,self.bag_provider,self.world_target_provider,self.world_ui_provider,self.group_npc_provider,self.direct_dialogue_provider,self.story_provider,self.map_quest_card_provider)):
            image, frame = self.window.capture_image()
            frame.update(captured_at=frame["observed_at"], window_signature=json.dumps(window.identity),
                session_id=self.session_id, character_id=snapshot["character_id"], observation_id=snapshot["observation_id"],
                pid=window.pid, physical_client_rect=list(window.client_rect), process_started_at=window.process_started_at)
            # Native capture supplies a pixel hash, not a frame ID. Use this
            # owned observation transaction consistently across UI/life/bag/port.
            frame["frame_id"] = frame.get("frame_id") or snapshot["observation_id"]
            captured_frame = frame
            frame["current_quest"] = snapshot.get("quest_state_read", {}).get("current_quest")
            frame['quest_state_read'] = snapshot.get('quest_state_read')
            # Preserve the same observed native objective baseline for direct
            # report binding. The consumer checks source/owner/occurrence/time;
            # copying never creates, refreshes or infers a false baseline.
            frame['objective_evidence'] = deepcopy(snapshot.get('objective_evidence', []))
            if 'objective_inventory' in snapshot:
                frame['objective_inventory']=deepcopy(snapshot['objective_inventory'])
            frame["quest_state_proofs"] = snapshot.get("state_proofs", [])
            frame['objective_programs'] = snapshot.get('objective_programs', [])
            frame['operation_binding'] = deepcopy(snapshot.get('operation_binding'))
            frame['action_requests'] = snapshot.get('action_requests', [])
            frame['pending_input'] = snapshot.get('pending_input')
            frame['pointer_probe_receipt']=current_pointer_probe_receipt(snapshot.get('pending_input'))
            frame['input_owner'] = snapshot.get('input_owner')
            frame['offers'] = snapshot.get('offers', [])
            frame['current_autoplay'] = (snapshot.get('identity') or {}).get('current_autoplay')
            self.last_frame=frame
            image.info["captured_at"] = frame["captured_at"]
            if self.context_reader is not None:
                context = self._observe('current_context', snapshot, self.context_reader.identify, image, window)
                context["frame_observed_at"] = frame["captured_at"]
                snapshot["field_hud"] = {"confirmed": context.get("field_hud") is True,
                    "source": context["source"], "observed_at": frame["captured_at"],
                    "session_id": self.session_id, "character_id": snapshot["character_id"]}
                snapshot["current_context"] = context
                frame["current_context"] = context
                if context.get("ok"):
                    left, top, right, bottom = context["safe_rect"]
                    snapshot["layout"] = {"pid": window.pid, "viewport": [image.width, image.height],
                        "safe_rect": [left, top, right-left, bottom-top], "canvas_scale": context["canvas_scale"],
                        "orientation": context["profile"]["orientation"], "observed_at": frame["captured_at"],
                        "ui_percent": context["profile"].get("percent"),
                        "source": context["source"], "certainty": "confirmed", "context_scope": context["scope"]}
                else:
                    snapshot["missing"].extend(context.get("missing", []))
            normalized=node.get('params',{}).get('typed_operands') or {}
            if self.group_npc_provider is not None and normalized.get('ready') and 'npc_group_tag' in normalized.get('operands',{}):
                targets=self._observe('current_group_npc_targets',snapshot,self.group_npc_provider,
                    window=window,node=node,snapshot=snapshot,frame=frame,image=image)
                snapshot['group_npc_read']=targets
                for key,default in (('current_group_npc_targets',[]),('current_group_npc_observation',None)):
                    snapshot[key]=targets.get(key,default)
                    frame[key]=deepcopy(snapshot[key])
                snapshot['missing'].extend(targets.get('missing',[]))
            if self.ui_provider is not None:
                ui = self._observe('current_ui', snapshot, self.ui_provider.read, image, frame=frame, graph=self.graph, node=node, ocr=self.ocr)
                snapshot["current_ui"] = ui
                snapshot["missing"].extend(ui.get("missing", []))
                snapshot["action_proposals"].update(ui.get("action_proposals", {}))
                snapshot.setdefault('ui_effect_proofs', []).extend(ui.get('effect_proofs', []))
                snapshot['ui_effect_proofs'].extend(ui.get('ui_effect_proofs', []))
                snapshot['interaction_ready'] = ui.get('interaction_ready', [])
            for family,provider,operations in (
                    ('direct_dialogue',self.direct_dialogue_provider,('move.target','dialogue.accept','dialogue.talk','dialogue.report','objective.deliver')),
                    ('story',self.story_provider,('objective.read_story','objective.use_quest_item'))):
                if provider is not None and node.get('op') in operations:
                    result=self._observe(family,snapshot,provider,
                        window=window,node=node,snapshot=snapshot,frame=frame,image=image)
                    self._merge_ui_boundary(family,result,snapshot)
            if node.get('op')=='objective.inspect':
                self._read_world_boundary(node,window,snapshot,frame,image)
            if self.life_provider is not None and self._supply_provider_needed(node,snapshot,'life.gather','native_gather'):
                life = self._observe('native_life', snapshot, self.life_provider, window=window, node=node, image=image, frame=frame, snapshot=snapshot)
                snapshot["life_read"] = life
                if life.get("ok"):
                    snapshot["native_life"] = life.get("native_life")
                snapshot["missing"].extend(life.get("missing", []))
            if self.bag_provider is not None:
                bag = self._observe('bag_recovery', snapshot, self.bag_provider, window=window, node=node, image=image, frame=frame, snapshot=snapshot)
                snapshot["bag_read"] = bag
                if bag.get("ok"):
                    snapshot["bag_recovery"] = bag.get("snapshot")
                    snapshot["bag_blocked"] = bag.get("snapshot", {}).get("bag_blocked")
                snapshot["missing"].extend(bag.get("missing", []))
                # Movement/report nodes ordinarily need no inventory. An actual
                # bag obstruction or persisted child recovery is a new boundary.
                if (snapshot.get("bag_blocked") is True or
                        (self.recovery_reader is not None and self.recovery_reader())) and "inventory_read" not in snapshot:
                    raw = self._observe('recovery_inventory', snapshot, self.cli.query, "get_items")
                    snapshot["inventory_read"] = raw
                    snapshot["inventory"] = inventory_from_read(raw, snapshot["character_id"], self.session_id)
                    if snapshot["inventory"] is None:
                        snapshot["missing"].append("fresh_inventory_response")
        else:
            snapshot["missing"].append("current_source_ui_binding_provider")
        return {'ok':True, 'image':image, 'frame':captured_frame}

    def _read_world_boundary(self,node,window,snapshot,frame,image):
        if self.world_target_provider is not None:
            targets=self._observe('current_world_targets',snapshot,self.world_target_provider,
                window=window,node=node,snapshot=snapshot,frame=frame,image=image)
            snapshot['world_targets_read']=targets
            snapshot['current_world_targets']=targets.get('current_world_targets',[])
            snapshot['current_world_target_observation']=targets.get('current_world_target_observation')
            frame['current_world_targets']=deepcopy(snapshot['current_world_targets'])
            frame['current_world_target_observation']=deepcopy(snapshot['current_world_target_observation'])
            snapshot['missing'].extend(targets.get('missing',[]))
        if self.world_ui_provider is not None:
            world_ui=self._observe('world_interaction_ui',snapshot,self.world_ui_provider,
                window=window,node=node,snapshot=snapshot,frame=frame,image=image)
            snapshot['world_ui_read']=world_ui
            snapshot['world_ui']=world_ui.get('world_ui')
            self._merge_ui_boundary('world_ui',world_ui,snapshot)

    def _read_quest_item_supply(self,node,window,snapshot,frame,image):
        if ('material_requirement' not in node.get('params',{})
                or node.get('params',{}).get('item',{}).get('table_domain')!='QuestFakeItemSheet'):
            return None
        from .quest_item_acquisition import prepare_quest_item_supply,project_quest_item_observation
        pending=snapshot.get('pending_input') or {}
        prepared=prepare_quest_item_supply(node,snapshot,pending.get('planner_receipt'))
        snapshot['quest_item_supply_preparation']=prepared
        if not prepared['ready'] or frame is None:
            snapshot['missing'].extend(prepared.get('missing',[]))
            return None
        projected=project_quest_item_observation(node,snapshot,prepared,frame=frame)
        if not projected['ready']:
            snapshot['missing'].extend(projected['missing'])
            return None
        observed=projected['snapshot']
        observed.update(action_proposals={},ui_effect_proofs=[],interaction_ready=[])
        self._read_world_boundary(projected['observation_node'],window,observed,projected['frame'],image)
        for key in ('world_targets_read','current_world_targets','current_world_target_observation','world_ui_read','world_ui'):
            if key in observed:snapshot[key]=observed[key]
        self._merge_ui_boundary('quest_item_supply',{
            'action_proposals':observed['action_proposals'],'ui_effect_proofs':observed['ui_effect_proofs'],
            'interaction_ready':observed['interaction_ready'],'missing':observed['missing']},snapshot)
        self.last_frame=projected['frame']
        return projected

    @staticmethod
    def _merge_ui_boundary(family,result,snapshot):
        """Keep source observations and reject conflicting current UI actions."""
        snapshot[family+'_read']=result
        snapshot['missing'].extend(result.get('missing',[]))
        proposals=snapshot['action_proposals']
        conflicts=snapshot.setdefault('conflicting_action_nodes',[])
        for node_id,proposal in result.get('action_proposals',{}).items():
            if node_id in conflicts:continue
            if node_id in proposals and proposals[node_id]!=proposal:
                proposals.pop(node_id)
                conflicts.append(node_id)
                snapshot['missing'].append('conflicting_current_source_proposals:'+node_id)
            else:proposals[node_id]=proposal
        snapshot.setdefault('ui_effect_proofs',[]).extend(result.get('ui_effect_proofs',[]))
        snapshot.setdefault('interaction_ready',[]).extend(result.get('interaction_ready',[]))
        snapshot.setdefault('interaction_adoptions',[]).extend(result.get('interaction_adoptions',[]))
        if family=='story' and isinstance(result.get('current_story_ui'),dict):
            snapshot['current_story_ui']=deepcopy(result['current_story_ui'])

    @staticmethod
    def _supply_provider_needed(node,snapshot,operation,kind):
        returning=snapshot.get('material_return_requested')
        if returning:
            return snapshot.get('material_return_kind')==kind
        if node['op']==operation:return True
        if node['op']!='inventory.hold':return False
        selected=snapshot.get('supply_selection')
        if selected is None:return True  # Caller did not configure selection.
        phase=((snapshot.get('pending_input') or {}).get('planner_receipt') or {}).get('phase','') or ''
        prefix={'native_gather':'gather.','native_alter':'alter.','native_craft':'craft.'}[kind]
        if phase:return phase.startswith(prefix)
        return (selected.get('candidate') or {}).get('kind')==kind
