"""Source-geometry actuators with the existing bounded life STATE reader.

Only the selected nonlegacy path constructs this adapter. Observed captions
confirm state/identity/materials; their boxes never become input coordinates.
Catalog order remains explicit trial state. Processing lists normalize their
prior/manual/native-focus scroll to the top before calculated ID traversal;
this does not claim that live uiData arrays have been read from game memory.
"""
from __future__ import annotations

import re

from . import catalog
from .adaptive_life import (AdaptiveLife,
                            compact, field_ready, observed_slots, wait_input_idle, _green_caption)
from .coordinate_store import CoordinateRef
from .errors import require
from .processing_recovery import (ProcessingViewUncertain, view_require,
                                  _observed_slot_detail, heading_visible, prepared_travel_popup_visible,
                                  prepared_travel_button_visible)
from .execution_rules import number, value, route, geometry, snapshot as execution_snapshot


class SourceLife(AdaptiveLife):
    def __init__(self, service, *, processing_port=None):
        super().__init__(service, processing_port=processing_port)
        self.allow_current_observation = True  # state reader only, never targets
        self._state_cache_image = None
        self._state_cache = {}
        self._processing_list_position = None
        self._gather_targets = {}

    def _runtime(self):
        factory = getattr(self.service, "_ui_route_runtime", None)
        view_require(callable(factory), "생활 원본 좌표 저장소가 연결되지 않았습니다.")
        runtime = factory()
        view_require(runtime.require_prepared, "생활 입력은 화면비 확인으로 준비된 좌표만 사용합니다.")
        snapshot = runtime.refresh(self.service.window)
        view_require(snapshot.available and snapshot.context is not None,
                     "현재 화면/UI 조건의 원본 계산 문맥이 없습니다: " + snapshot.reason)
        return runtime, snapshot

    def _state_is(self, expected, recipe=None):
        self.service.check()
        if self.group is None:
            return False
        image = self._image()
        return (self.state(self.group, image) == expected
                and (recipe is None or self.recipe_ready(recipe, image)))

    def _prepared_observation(self):
        runtime = self.service._ui_route_runtime()
        return runtime.prepared_bundle("life.observation")

    def _observation_rows(self, roles):
        # No Rect creation/viewport scaling here. Preparation owns geometry;
        # readers only address the frozen normalized crop by its semantic ID.
        node = getattr(self, "_gather_node", None)
        if node is not None and all(role.startswith("gather.detail.") for role in roles):
            rows = self.service._ui_route_runtime().prepared_bundle("gather.travel", node_id=node["id"],
                link_kind="fishing" if node["id"].startswith("fishing-") else "nearby")
        else:
            rows = self._prepared_observation()
        view_require(rows is not None, "생활 판독 범위 준비가 필요합니다.")
        return rows

    def _ocr_bands(self, *roles):
        rows = self._observation_rows(roles)
        return tuple(rows[role]["metadata"]["normalized_roi"] for role in roles)

    def _field_ready(self, image):
        from .source_common_observation import common_field_ready
        return common_field_ready(self.service, image)

    def _find(self, image, labels, bands, role):
        # Inherited capacity/receipt helpers still pass their legacy bands.
        # Replace those at the reader boundary, never after OCR has occurred.
        aliases = {"processing.collect": "processing.collect",
            "processing.facility.travel": "processing.facility.travel",
            "processing.register": "processing.register",
            "processing.materials": "processing.recipe.body",
            "processing.result.confirm": "processing.result.confirm",
            "processing.result.title": "processing.result.title"}
        if role in aliases and self._prepared_observation() is not None:
            bands = self._ocr_bands(aliases[role])
        return super()._find(image, labels, bands, role)

    def _prepared_state(self, group, image):
        title = next((g["name"] for g in catalog.processing_catalog()["groups"] if g["id"] == group), None)
        require(title is not None, "알려진 가공 시설이 아닙니다.")
        if self.processing_port is not None:
            owned = self.processing_port._owned_visual_state(group, image)
            if owned is not None:
                return owned
        if _observed_slot_detail(image, self.ocr, roi=self._ocr_bands("processing.slot.detail")[0]):
            return "slot_detail"
        result_title = self._find(image, "가공 완료", self._ocr_bands("processing.result.title"), "processing.result.title")
        if result_title:
            confirm = self._find(image, "확인", self._ocr_bands("processing.result.confirm"), "processing.result.confirm")
            if confirm and heading_visible(image, result_title.source_region) and heading_visible(image, confirm.source_region):
                return "reward"
        heading = self._find(image, title, self._ocr_bands("processing.facility.heading"), "processing.heading")
        if heading:
            material = self._find(image, "필요한 재료", self._ocr_bands("processing.recipe.body"), "processing.materials")
            if material and heading_visible(image, material.source_region):
                footer = self._ocr_bands("processing.register")
                if self._find(image, "가공하러 가기", footer, "processing.travel"):
                    return "remote_detail"
                if self._find(image, "가공하기", footer, "processing.register"):
                    return "local_detail"
            if not heading_visible(image, heading.source_region):
                return "unknown"
            # Remote overviews also contain completed slots and Collect All.
            # Recovery must not promote a missed Move caption to local state.
            return self.transition_state(group, image)
        if self._field_ready(image):
            return "field"
        return "facilities" if self.facility_list_ready(image) else "unknown"

    def facility_list_ready(self, image):
        """One shared prepared list observation for fixed/source recovery."""
        heading = self._find(image, "가공", self._ocr_bands("processing.menu.heading"), "processing.facilities.heading")
        if heading and heading_visible(image, heading.source_region):
            names = {compact(row["name"]) for row in catalog.processing_catalog()["groups"]}
            cards = self._rows(image, self._ocr_bands("processing.facilities.cards"))
            if any(compact(row.text) in names and heading_visible(image, row.region) for row in cards):
                return True
        return False

    def transition_state(self, group, image):
        """Read only the genuine move/local/arrival branch of an owned route.

        No facility-name approval, recipe/material OCR or HUD scan here.
        Upgrade can share Move's gray backing, so its role is not inferred
        merely from color or from a failed OCR result.
        """
        port = self.processing_port
        runtime = self.service._ui_route_runtime()
        plain = runtime.prepared_bundle("processing.facility.travel", facility_id=group,
            remote=True, move_visible=True, upgrade_visible=False)["processing.facility.travel"]
        lane = self._ocr_bands("processing.facility.travel")[0]
        choice_present = prepared_travel_button_visible(image, lane, fraction=.12)
        if port is not None:
            slots, upgrade = port._read_source_queue(image, group=group)
            if slots and not choice_present and heading_visible(image, self._ocr_bands("processing.facility.heading")[0]):
                port._remember_visible_queue(group, image, slots, upgrade_visible=upgrade)
                return "local"
        # A small authored row is the only optional role OCR in this branch.
        # It is independent of the title, which includes facility level text.
        candidates = self._rows(image, (lane,)) if choice_present else []
        moves = [row for row in candidates if compact(row.text) == compact("설비로 이동")]
        if len(moves) == 1 and heading_visible(image, moves[0].region):
            center_x = (moves[0].region[0]+moves[0].region[2])/2*image.width
            if port is not None:
                port._move_upgrade_visible = center_x > plain["rect"].right
            return "remote"
        if port is not None and port._travel_group == group:
            register = self._ocr_bands("processing.register")[0]
            if _green_caption(image, register) and heading_visible(image, self._ocr_bands("processing.recipe.body")[0]):
                # An unusual arrival into a remembered recipe needs its actual
                # action caption, not green terrain/HUD plus bright field text.
                # The ordinary list-arrival path above performs no such read.
                action = self._find(image, "가공하기", (register,), "processing.register")
                if action is not None and heading_visible(image, action.source_region):
                    return "local_detail"
        # Neither elapsed movement nor a failed Move caption proves arrival.
        return "unknown"

    def recipe_ready(self, recipe, image=None):
        if self._prepared_observation() is None:
            return super().recipe_ready(recipe, image)
        image = self._image() if image is None else image
        if self.processing_port is not None and self.processing_port._owned_detail_ready(recipe, image):
            return True
        if self.state(recipe["group"], image) != "local_detail":
            return False
        rows = self._rows(image, self._ocr_bands("processing.recipe.body"))
        headings = [row for row in rows if compact(row.text) == "필요한재료"]
        if len(headings) != 1:
            return False
        material = headings[0]
        # Compare row order inside the ONE source-parent read. Neither the
        # heading's observed position nor text height is used to invent a crop.
        names = [row for row in rows if compact(row.text) == compact(self._display_name(recipe))
                 and row.region[3] <= material.region[1]]
        if len(names) != 1:
            return False
        known = {compact(m["name"]) for row in catalog.processing_catalog()["recipes"] for m in row["materials"]}
        actual = {compact(row.text) for row in rows if row.region[1] >= material.region[3]
                  and compact(row.text) in known}
        register_roi = self._ocr_bands("processing.register")[0]
        return (actual == {compact(m["name"]) for m in recipe["materials"]}
                and _green_caption(image, register_roi))

    def _gather_detail_ready(self, node, image=None):
        image = self._image() if image is None else image
        self._required(image, node["name"], self._ocr_bands("gather.detail.title"), "gather.detail.title")
        requirement_roi = self._ocr_bands("gather.detail.requirements")[0]
        rows = self._rows(image, (requirement_roi,))
        require(any(re.search(r"(?:lv\.?|레벨)\s*\d", row.text, re.I) for row in rows),
                "채집 필요 레벨 표시를 확인하지 못했습니다.")
        require(self._gather_requirements_allow(image),
                "채집 레벨 또는 해제 조건이 부족합니다.")
        expected = "낚시터로이동" if node["id"].startswith("fishing-") else "가까운위치찾기"
        links = [row for row in self._rows(image, self._ocr_bands("gather.detail.link"))
                 if compact(row.text).rstrip("?@") in {"가까운위치찾기", "낚시터로이동"}]
        require(len(links) == 1 and compact(links[0].text).rstrip("?@") == expected,
                "선택 품목의 이동 종류를 정확히 확인하지 못했습니다.")
        return True

    def _gather_requirements_allow(self, image):
        """Retain the observed red restriction without requiring level OCR."""
        from .gather_geometry import prepared_requirement_locked
        role = "gather.detail.requirements"
        observation = self._observation_rows((role,))[role]["metadata"]
        return not prepared_requirement_locked(image, observation["normalized_roi"],
            fraction=number("life.requirement.red_fraction", .008),
            exclusions=observation.get("normalized_exclusions", ()))

    def _publish(self, scene, role, state, guard):
        runtime, _ = self._runtime()
        reference = runtime.prepared_ref(scene, role, **state)
        if scene.startswith("gather."):
            # Preserve semantic ownership without restoring the old per-click
            # OCR guards. Geometry generations alone do not track user input.
            self._gather_targets[reference] = (
                getattr(self, "_gather_context", None),
                getattr(self, "_gather_stage", None),
                (getattr(self, "_gather_node", None) or {}).get("id"))
        return reference

    def guard_target(self, target, *, expected_state=None, recipe=None):
        if not isinstance(target, CoordinateRef):
            return False
        if expected_state is not None and not self._state_is(expected_state, recipe):
            return False
        if expected_state is None and recipe is not None and not self.recipe_ready(recipe):
            return False
        runtime, _ = self._runtime()
        if target in self._gather_targets:
            context, stage, node_id = self._gather_targets[target]
            view_require(context is not None and context == (
                runtime.snapshot.signature, getattr(self.service, "input_resume_generation", 0)) and
                stage == getattr(self, "_gather_stage", None) and
                node_id == (getattr(self, "_gather_node", None) or {}).get("id"),
                "채집 경로 중 사용자 입력/화면 변경 · 미전송한 현재 선택 단계 재확인")
        runtime.resolve_input(target, self.service.window)
        return True

    def _click(self, target, *, processing=False, guard=None):
        view_require(isinstance(target, CoordinateRef), "원본 생활 입력은 좌표 ID만 사용할 수 있습니다.")
        sender = self.service.processing_input if processing else self.service.input
        sent = sender("click", target, guard=lambda: (guard is None or guard()) and self.guard_target(target))
        view_require(sent is not False, "생활 계산 좌표 입력 보류 · 상태 재확인 필요")

    def _visible_queue_count(self):
        port = self.processing_port
        if port is not None:
            # Layout is generated by the facility-level slot table. A partial
            # ring observation must never collapse a wrapped row or move the
            # recipe list/GetAll geometry to a different count variant.
            return port.facility_capacity(self.group)
        # Standalone inspection adapter has no execution port/actuators.
        image = self._image()
        view_require(self.state(self.group, image) == "local",
                     "현장 목록에서 표시 슬롯 수 확인 필요")
        label = self._required(image, "모두 받기", (geometry("life.observation.legacy", image=image)["left"],), "processing.collect")
        slots = observed_slots(image, label)
        view_require(bool(slots), "표시 슬롯 수를 읽지 못했습니다. 작업 수로 대체하지 않습니다.")
        return len(slots)

    def state(self, group, image=None):
        image = self._image() if image is None else image
        # Same-frame observations share their read, not an old frame's result.
        digest = execution_snapshot().digest
        if image is not self._state_cache_image or getattr(self, "_state_rule_digest", None) != digest:
            self._state_cache_image, self._state_cache = image, {}
            self._state_rule_digest = digest
        if group not in self._state_cache:
            self._state_cache[group] = self._prepared_state(group, image)
        return self._state_cache[group]

    def _processing_list_target(self, scene, role, state, expected):
        """Normalize an unknown/manual/native-focus offset, then seek by ID.

        The producer owns every scroll/click rectangle. Execution retains only
        semantic list state, never a second coordinate table or OCR click box.
        """
        runtime, snapshot = self._runtime()
        signature = snapshot.signature
        generation = getattr(self.service, "input_resume_generation", 0)
        position_key = (scene, role, signature, generation)
        if self._processing_list_position is not None and self._processing_list_position[0] == position_key:
            return self._publish(scene, role, self._processing_list_position[1], lambda: True)
        state = dict(state, focus_id=role.rsplit(".", 1)[-1], scroll_phase="reset")
        def current():
            self.service.check()
            _, live = self._runtime()
            return (live.signature == signature and
                    getattr(self.service, "input_resume_generation", 0) == generation)
        getattr(self.service, "status", lambda _: None)(
            "가공 목록 · 이전 스크롤 최상단 복귀 후 선택 품목으로 계산 이동")
        # Catalog lists are bounded512; each input handles at most5 notches.
        # A failure retains no guessed offset: the caller retries from reset.
        for _ in range(4096):
            view_require(current(), "목록 이동 중 화면/UI/사용자 입력 변경 · 현재 목록부터 재확인")
            ref = self._publish(scene, scene + ".scroll", state, current)
            plan = runtime.resolve_input(ref, self.service.window).metadata
            delta = plan["next_wheel_delta"]
            if not delta:
                if state["scroll_phase"] == "reset":
                    state.update(scroll_phase="seek", scroll_y=0, reset_remaining_notches=0)
                    continue
                self._processing_list_position = (position_key, dict(state))
                return self._publish(scene, role, state, current)
            try:
                sent = self.service.processing_input("scroll", (ref, delta),
                    guard=lambda: self.guard_target(ref))
            finally:
                runtime.release_scene()
            view_require(sent is not False and current(), "목록 스크롤 결과 재확인 · 이전 좌표 사용 안 함")
            state.update(scroll_y=plan["next_scroll_y"], scroll_phase=plan["next_scroll_phase"],
                         reset_remaining_notches=plan["reset_remaining_notches"])
        raise ProcessingViewUncertain("가공 목록 계산 스크롤 범위를 다시 확인합니다.")

    def visit(self, recipe):
        port = self.processing_port
        if port is not None:
            port._focus_for_visit(recipe["group"])
        else:
            self.service.window.focus()
        group = recipe["group"]
        if self.group is not None and self.group != group:
            self.leave()
        state = port.owned_surface(group) if port is not None else None
        if state is None:
            image = self._image()
            state = self.state(group, image)
            if state == "unknown" and port is not None and port._leave_other_observed_facility(group, image):
                state = "field"
            if port is not None:
                port._remember_surface(group, state)
        if self.processing_port is not None and state in ("field", "facilities", "remote", "local", "local_detail", "remote_detail", "slot_detail"):
            self.processing_port._owned_view = True
        if state in ("local_detail", "remote_detail", "slot_detail"):
            self.close_detail(group)
            state = port.owned_surface(group) if port is not None else self.state(group)
        view_require(state in ("field", "facilities", "remote", "local"), "가공 시작 화면 확인 필요")
        self.group = group
        context = port._surface_context() if port is not None else None
        current = (lambda: port._context_guard(context)) if port is not None else (lambda: True)
        with self.service.coordinate_sequence():
            if state == "field":
                steps = route("processing.visit.prepared", ("menu", "tab"))
                require(isinstance(steps,(list,tuple)) and 2 <= len(steps) <= 12 and
                        all(isinstance(step,str) and step in ("menu","tab") for step in steps) and
                        steps[0] == "menu" and steps[-1] == "tab" and steps.count("menu") == 1,
                        "가공 준비 선택 규칙의 단계가 잘못되었습니다.")
                for step in steps:
                    if step == "menu":
                        sent = self.service.processing_input("key", "K", guard=current)
                        view_require(sent is not False, "가공 메뉴 입력 보류")
                    else:
                        target = self._publish("processing.tabs", "processing.tab.Altering", {
                            "ordered_ids": ("QuickCrafting", "Altering", "SkillCrafting", "Alchemy"),
                            "order_basis": "source905-branch-order-initial-candidate-not-live-buttons-array"}, lambda: True)
                        self._click(target, processing=True, guard=current)
                state = "facilities"
            if state == "facilities":
                self.select_facility(group, guard=current)
                self.service.coordinate_step()
        if state == "facilities":
            if port is not None:
                state = port._wait(group, ("remote", "local"))
            else:
                image = self._wait_state(group, ("remote", "local"))
                state = self.state(group, image)
        if state == "remote":
            target = self._publish("processing.facility.travel", "processing.facility.travel", {
                "facility_id": group, "remote": True, "move_visible": True,
                "upgrade_visible": getattr(port, "_move_upgrade_visible", False),
                "branch_basis": "owned-remote-upgrade-hidden-initial-candidate"},
                lambda: self._state_is("remote"))
            if self.processing_port is not None:
                self.processing_port._travel(group, target)
            else:
                self._click(target, processing=True)
                self._wait_state(group, ("local",), timeout=600)
            state = "local"
        view_require(state == "local", "가공 현장 목록이 아니므로 등록·수령하지 않았습니다.")
        self.group, self.recipe = group, None
        if self.processing_port is not None:
            self.processing_port.recovery_opened(group, "local")
        return {"group": group, "state": "local"}

    def select_facility(self, group, *, guard=None):
        """The existing facility-list suffix, also used after a missed click."""
        ids = tuple(row["id"] for row in catalog.processing_catalog()["groups"])
        target = self._processing_list_target("processing.facilities", "processing.facility." + group, {
            "ordered_ids": ids, "scroll_x": 0, "scroll_y": 0,
            "order_basis": "authored-catalog-order-not-live-uiData; explicit-top-reset"}, "facilities")
        self._click(target, processing=True, guard=guard)

    def select(self, recipe):
        require(self.group == recipe["group"], "선택한 가공 시설 소유권 불일치")
        actual = next((row for row in catalog.processing_catalog()["recipes"] if row["id"] == recipe.get("id")), None)
        require(actual is not None and all(actual[key] == recipe[key] for key in ("name", "group", "materials")),
                "가공 도감 품목 불일치")
        runtime, snapshot = self._runtime()
        state = dict(ordered_ids=tuple(row["id"] for row in catalog.processing_catalog()["recipes"]
                                      if row["group"] == self.group), facility_id=self.group,
                     scroll_x=0, scroll_y=0, subtract_padding=True,
                     order_basis="authored-catalog-order-not-live-itemRecipeList; explicit-top-reset")
        if snapshot.orientation == "portrait":
            state.update(queue_count=self._visible_queue_count(), move_visible=False, upgrade_visible=False,
                         branch_basis="owned-local-upgrade-hidden-initial-candidate")
            state["upgrade_visible"] = getattr(self.processing_port._queue_observation, "upgrade_visible", False)
        target = self._processing_list_target("processing.recipes", "processing.recipe." + recipe["id"], state, "local")
        port = self.processing_port
        context = port._queue_context() if port is not None else None
        self._click(target, processing=True,
                    guard=(lambda: port._tape_guard(self.group, context)) if port is not None else None)
        if self.processing_port is not None:
            self.processing_port.remember_selection(recipe)
        if self.processing_port is not None and self.processing_port.local_owned(self.group):
            self.recipe = recipe
            return self.tape_target("register", recipe)
        image = self._wait_state(self.group, ("local_detail",))
        if self.processing_port is not None:
            self.processing_port.recovery_opened(self.group, "local_detail")
        view_require(self.recipe_ready(recipe, image), "계산으로 연 상세의 정확 품목·재료가 달라 등록하지 않습니다.")
        self.recipe = recipe
        return self.register_target(image=image)

    def tape_target(self, action, recipe=None):
        """Existing processing tape reads prepared IDs, not observed captions."""
        runtime, _ = self._runtime()
        port = self.processing_port
        view_require(runtime.require_prepared and port is not None and port.local_owned(self.group),
                     "현재 시설의 준비된 가공 입력 소유권이 없습니다.")
        if action in ("register", "storage_confirm"):
            recipe = self.recipe if recipe is None else recipe
            require(recipe is not None and recipe["group"] == self.group, "가공 품목 소유권 불일치")
            if action == "storage_confirm":
                return runtime.prepared_ref("processing.storage_confirm", "processing.storage_confirm",
                    facility_id=self.group, recipe_id=recipe["id"])
            return runtime.prepared_ref("processing.register", "processing.register",
                facility_id=self.group, recipe_id=recipe["id"], local_detail=True,
                register_enabled=True, settled=True, scroll_y=0)
        if action == "collect":
            observed = port._queue_observation
            visible_count = self._visible_queue_count()
            return runtime.prepared_ref("processing.collect", "processing.collect_all",
                queue_count=visible_count, facility_ready=True, full_safe_area=True,
                collect_enabled=True, move_visible=False, upgrade_visible=getattr(port._queue_observation, "upgrade_visible", False),
                branch_basis="owned-local-upgrade-hidden-initial-candidate")
        require(action == "result_confirm", "등록되지 않은 가공 입력 단계")
        return runtime.prepared_ref("processing.result", "processing.result.confirm", settled=True)

    def capacity(self, group, rows):
        port = self.processing_port
        if port is not None:
            return port.capacity(group, rows)
        return super().capacity(group, rows)

    def register_target(self, image=None):
        recipe = self.recipe
        # select() has just confirmed this frame; reuse its row cache rather
        # than recapturing before returning a reference. Actual registration
        # still calls guard_target with the current recipe/material state.
        view_require(recipe is not None and self.recipe_ready(recipe, image), "등록 전 정확 품목·재료 확인 필요")
        return self._publish("processing.register", "processing.register", {
            "facility_id": self.group, "recipe_id": recipe["id"], "local_detail": True,
            "register_enabled": True, "settled": True, "scroll_y": 0},
            lambda: self._state_is("local_detail", recipe))

    def collect_target(self):
        view_require(self._state_is("local"), "수령 전 가공 현장 목록 확인 필요")
        return self._publish("processing.collect", "processing.collect_all", {
            "queue_count": self._visible_queue_count(), "facility_ready": True,
            "full_safe_area": True, "collect_enabled": True, "move_visible": False,
            "upgrade_visible": False, "branch_basis": "owned-local-upgrade-hidden-initial-candidate"},
            lambda: self._state_is("local"))

    def result_confirm_target(self):
        view_require(self._state_is("reward"), "가공 완료 결과 확인 필요")
        self.service.processing_pause(number("processing.result.settle", 1.1))
        return self._publish("processing.result", "processing.result.confirm", {"settled": True},
                             lambda: self._state_is("reward"))

    def travel_confirm_target(self, group, image=None):
        """Use the owned move request and prepared cancel/confirm signature.

        The caller still owns request generation, retry spacing and before_send
        timestamps. No title/question OCR is added before the known confirm
        coordinate; direct travel has no popup and sends no confirmation.
        """
        port = self.processing_port
        view_require(port is not None and port._travel_group == group,
                     "실제로 전송한 설비 이동 요청의 확인창만 처리합니다.")
        current_image = self._image() if image is None else image
        confirm, cancel = self._ocr_bands("processing.travel.confirm", "processing.travel.cancel")
        if not prepared_travel_popup_visible(current_image, confirm, cancel):
            # Direct travel and the HUD after confirming have no popup. Let
            # the existing T/arrival state machine continue in those cases.
            return None
        return self._publish("processing.travel.confirm", "processing.travel.confirm", {
            "facility_id": group, "owned_travel_request": True,
            "question_confirmed": True, "settled": True}, lambda: True)

    def close_detail_target(self):
        # Existing guarded Escape latch closes owned details without guessing
        # a close-button formula from an observed X or a legacy normalized box.
        return None

    def gather_tape(self, node):
        from .automation import wait_route_input_idle
        groups = catalog.gather_catalog()["groups"]
        matches = [(group, actual) for group in groups for actual in group["nodes"]
                   if actual["id"] == node.get("id")]
        require(len(matches) == 1 and matches[0][1]["name"] == node.get("name"), "채집 도감 품목 불일치")
        group, actual = matches[0]
        self.service.check()
        wait_route_input_idle(self.service, wait_input_idle)
        self.service.window.focus()
        inherited = getattr(self.service, "_processing_field_owner", None)
        if not (callable(inherited) and inherited()):
            view_require(self._field_ready(self._image()), "채집 시작 기본 화면 재확인")
        self._gather_node = dict(actual)
        self._gather_targets.clear()
        self._gather_stage = "profile"
        self._gather_context = (self._runtime()[1].signature,
                               getattr(self.service, "input_resume_generation", 0))
        def profile_guard():
            image = self._image()
            return (self._gather_stage == "profile" and not field_ready(self.ocr, image)
                    and self._find(image, ("생활", "생활 스킬"), geometry("life.observation.legacy", image=image)["nav"], "gather.tab.state") is not None)
        def category_guard():
            image = self._image()
            return (self._gather_stage == "categories" and not field_ready(self.ocr, image)
                    and self._find(image, group["name"], (geometry("life.observation.legacy", image=image)["category"],), "gather.category.state") is not None)
        steps = route("life.gather.open.prepared", ("profile", "tab", "category"))
        require(isinstance(steps,(list,tuple)) and 3 <= len(steps) <= 12 and
                all(isinstance(step,str) and step in ("profile","tab","category") for step in steps) and
                steps[0] == "profile" and steps[-1] == "category" and steps.count("profile") == 1 and "tab" in steps,
                "채집 준비 선택 규칙의 단계가 잘못되었습니다.")
        for step in steps:
            if step == "profile":
                context = self._gather_context
                sent = self.service.input("key", "C", guard=lambda: context == (
                    self._runtime()[1].signature, getattr(self.service, "input_resume_generation", 0)))
                view_require(sent is not False, "채집 프로필 메뉴 입력 보류")
            elif step == "tab":
                tab = self._publish("gather.tabs", "gather.tab.LivingSkill", {
                    "ordered_ids": ("State", "CombatSkill", "LivingSkill", "Class", "MemorizeCollectionRune"),
                    "order_basis": "source1354-branch-order-with-rune-tab-initial-candidate-not-live-buttons"}, profile_guard)
                self._click(tab)
                self._gather_stage = "categories"
            else:
                category = self._publish("gather.categories", "gather.category." + group["id"], {
                    "ordered_ids": tuple(row["id"] for row in groups), "scroll_x": 0, "scroll_y": 0,
                    "order_basis": "catalog-order-and-initial-scroll-candidate-not-live-LivingSkills"}, category_guard)
                self._click(category)
        self._gather_stage = "list"
        names = tuple(row["name"] for row in group["nodes"])
        def list_guard():
            image = self._image()
            return (self._gather_stage == "list" and self._gather_node == actual and not field_ready(self.ocr, image)
                    and self._find(image, group["name"], (geometry("life.observation.legacy", image=image)["category"],), "gather.category.state") is not None)
        state = dict(ordered_ids=tuple(row["id"] for row in group["nodes"]), category_id=group["id"],
            scroll_x=0, scroll_y=0, focus_id=actual["id"],
            order_basis="catalog-node-order-and-initial-scroll0-candidate-not-live-elements")
        # Prepared mode reads the entire scroll tape from the shared container.
        # Execution only changes semantic scroll state, never calculates pixels.
        for _ in range(256):
            scroll = self._publish("gather.items", "gather.list.scroll", state, list_guard)
            runtime, _ = self._runtime()
            plan = runtime.resolve_input(scroll, self.service.window).metadata
            delta = plan["next_wheel_delta"]
            if not delta:
                break
            try:
                sent = self.service.input("scroll", (scroll, delta), guard=lambda: self.guard_target(scroll))
            finally:
                runtime.release_scene()
            view_require(sent is not False, "채집 목록 스크롤 보류 · 위치를 추측해 선택하지 않습니다.")
            state["scroll_y"] = plan["next_scroll_y"]
        else:
            raise ProcessingViewUncertain("채집 목록의 소스 스크롤 계획 한도 초과")
        def item_guard():
            image = self._image()
            rows = self._page_rows(image, names, geometry("life.observation.legacy", image=image)["list"])
            return list_guard() and sum(compact(row.text) == compact(actual["name"]) for row in rows) == 1
        target = self._publish("gather.items", "gather.item." + actual["id"], state, item_guard)
        self._click(target)
        self._gather_stage = "detail"
        self._runtime()[0].release_scene()
        # Selected ID ownership and actual red restrictions remain separate
        # from coordinate lookup; there is no OCR click-target fallback.
        return self.gather_link(actual)

    def recover_gather_selection(self, node):
        """Exception-only recovery; normal prepared tapes never call this reader.

        A correctly selected detail continues directly. Only known life menus
        are closed to rebuild an interrupted, non-consuming selection tape.
        Unknown/covered frames cause another observation, not ESC or a stop.
        """
        from .errors import InputInterrupted, ObservationUnavailable
        from .fullscreen_popups import recover_fullscreen_popup
        from .automation import wait_route_input_idle
        groups = catalog.gather_catalog()["groups"]
        group, actual = next((g, n) for g in groups for n in g["nodes"] if n["id"] == node["id"])
        self._gather_node = dict(actual)
        while True:
            self.service.check()
            try:
                wait_route_input_idle(self.service, wait_input_idle)
                if recover_fullscreen_popup(self.service):
                    continue
                image = self._image()
                if self._field_ready(image):
                    self._gather_stage = "field"
                    return None
                title = self._find(image, actual["name"], self._ocr_bands("gather.detail.title"), "gather.detail.title")
                expected = "낚시터로 이동" if actual["id"].startswith("fishing-") else "가까운 위치 찾기"
                link = self._find(image, (expected, expected + " ?"), self._ocr_bands("gather.detail.link"), "gather.detail.link")
                if title and link:
                    self._gather_stage = "detail"
                    self._gather_context = (self._runtime()[1].signature,
                        getattr(self.service, "input_resume_generation", 0))
                    return self.gather_link(actual, image)
                bands = geometry("life.observation.legacy", image=image)
                known = self._find(image, ("생활 스킬", group["name"]), (bands["category"],), "gather.category.state")
                profile = self._find(image, ("생활", "생활 스킬"), bands["nav"], "gather.tab.state")
                if known or profile:
                    self.service.input("key", "ESC")
            except (ProcessingViewUncertain, InputInterrupted, ObservationUnavailable):
                pass
            self.service.sleep(.5)

    def gather_link(self, node, image=None):
        require(getattr(self, "_gather_node", None) == node and getattr(self, "_gather_stage", None) == "detail",
                "이번 경로에서 선택한 채집 품목 소유권 불일치")
        def owned():
            self.service.check()
            return (getattr(self, "_gather_node", None) == node and self._gather_stage == "detail"
                    and getattr(self, "_gather_context", None) == (self._runtime()[1].signature,
                        getattr(self.service, "input_resume_generation", 0)))
        # The complete prepared tape already selected the exact catalog ID.
        # The actual red restriction remains a current-pixel check, not OCR
        # reapproval of the selected identity and non-consuming movement.
        view_require(owned(), "채집 선택 뒤 사용자 입력/창 변경 · 현재 단계 재확인 필요")
        self.service.coordinate_step()
        require(self._gather_requirements_allow(self._image()), "채집 레벨 또는 해제 조건이 부족합니다.")
        def current():
            self.service.check()
            if getattr(self, "_gather_node", None) != node or self._gather_stage != "detail":
                return False
            return owned() and self._gather_requirements_allow(self._image())
        return self._publish("gather.travel", "gather.travel", {
            "node_id": node["id"], "detail_confirmed": True, "requirement_met": True,
            "link_confirmed": True, "settled": True, "scroll_y": 0,
            "link_kind": "fishing" if node["id"].startswith("fishing-") else "nearby"}, current)
