"""Travel snack: free CLI stock and main's authored crafting tape only.

Sources: main automation/direct_food.py and
FIRST-VISIT-SNACK-02325.md. No material supply, paid CLI or default-food change.
The original full-screen references are archived; never ship their screenshots.
"""
from __future__ import annotations
from .fullscreen_popups import acknowledge_popup_return, recover_fullscreen_popup

import time

from .errors import InputInterrupted, ObservationUnavailable, require
from .focused_ocr import FocusedOCR, normalized
from .route_geometry import crop_bounds
from .execution_rules import geometry, number, value, route
from .windows import last_input_stamp

SNACK = "여행자 간식"
MATERIALS = ("달걀", "고기")
BATCH = 10
LOW_STOCK = 2


_GEOMETRY_EXPORTS = frozenset((
    "CRAFT_TAPE", "CRAFT_COUNT", "COUNT_BLUR", "CRAFT_BUTTON", "CRAFT_ACK",
    "CRAFT_RESULT_STABLE_SECONDS",
    "CRAFT_RESULT_RETRY_SECONDS", "FIXED_OCR_REGIONS"))
_GEOMETRY_REGION_EXPORTS = {
    "RECIPE_TITLE": "ocr.snack.recipe.title", "RECIPE_BUTTON": "ocr.snack.recipe.button",
    "COUNT_TEXT": "ocr.snack.count", "MATERIAL_TEXT": "ocr.snack.materials",
    "CRAFT_RESULT_TEXT": "ocr.snack.result.title", "CRAFT_RESULT_ITEM_TEXT": "ocr.snack.result.item",
    "CRAFT_RESULT_FOOTER_TEXT": "ocr.snack.result.footer", "CRAFT_RESULT_BUTTON_BODY": "ocr.snack.result.body",
}


def __getattr__(name):
    # Compatibility for callers inspecting the old names. Execution reads the
    # prepared snapshot below, never an import-time copy of external geometry.
    if name in _GEOMETRY_EXPORTS:
        return geometry("snack." + name)
    if name in _GEOMETRY_REGION_EXPORTS:
        return geometry("snack.FIXED_OCR_REGIONS")[_GEOMETRY_REGION_EXPORTS[name]]
    raise AttributeError(name)


def bag_stock(rows):
    """One snapshot, exact identities, inventory only, no inferred locks."""
    _observed(isinstance(rows, list), "간식 재고 목록 형식 미확인")
    result = {}
    for name in (SNACK, *MATERIALS):
        matches = [r for r in rows if isinstance(r, dict) and r.get("DisplayName") == name
                   and r.get("Location") == "inventory"]
        _observed(len({r.get("Category") for r in matches}) <= 1, "간식 재고 품목 분류 중복: " + name)
        _observed(all(type(r.get("Count")) is int and r["Count"] >= 0 and
                    type(r.get("IsLocked")) is bool for r in matches), "간식 재고 수량/잠금 상태 미확인: " + name)
        result[name] = dict(total=sum(r["Count"] for r in matches),
                            locked=sum(r["Count"] for r in matches if r["IsLocked"]))
    return result


def _observed(condition, message):
    if not condition:
        raise ObservationUnavailable(message)


def validate_quantity(quantity):
    require(type(quantity) is int and quantity > 0, "여행자 간식 제작 횟수는 양수 정수여야 합니다.")
    return quantity


def validate_materials(stock, quantity=BATCH):
    """Use only unlocked bag materials; shortage is a smaller or empty batch."""
    validate_quantity(quantity)
    return min(quantity, *(max(0, stock[name]["total"] - stock[name]["locked"]) for name in MATERIALS))


class TravelSnack:
    fixed_coordinates = True
    def __init__(self, service):
        self.s = service
        self.craft_confirmed = False
        self._recipe_owner = None

    def _retry_observation(self, operation, message):
        """Retry only the current non-consuming observation/cleanup stage."""
        last_log = float("-inf")
        while True:
            self.s.check()
            try:
                return operation()
            except (ObservationUnavailable, InputInterrupted) as exc:
                now = time.monotonic()
                if now - last_log >= number("snack.observation.report_interval", 15):
                    self.s.status(message + " · 같은 단계 재확인 · 소비 입력 재전송 없음 · " + str(exc))
                    last_log = now
                self.s.sleep(number("snack.observation.poll_interval", 1))

    def _route_context(self):
        window = self.s.window
        return (getattr(window,"hwnd",None), getattr(window,"pid",None),
                getattr(window,"width",None), getattr(window,"height",None),
                getattr(window,"layout_generation",None),
                getattr(self.s,"input_resume_generation",0))

    def _recipe_regions(self, image):
        return (self._region("ocr.snack.recipe.button", image),
                self._region("ocr.snack.materials", image))

    def _geometry_value(self, name, image=None):
        factory = getattr(self.s, "_ui_route_runtime", None)
        return geometry("snack." + name, image=image,
                        runtime=factory() if callable(factory) else None)

    def _region_role(self, region, image=None):
        if isinstance(region, str):
            return region
        return next((key for key, item in self._geometry_value("FIXED_OCR_REGIONS", image).items()
                     if tuple(item) == tuple(region)), None)

    def _region(self, region, image=None):
        role = self._region_role(region, image)
        require(role is not None, "간식 판독 영역 ID가 등록되지 않았습니다.")
        return tuple(self.s._ui_route_runtime().prepared_bundle("snack.fixed")[role]["metadata"]["normalized_roi"])

    def _observation_checkpoint(self):
        factory = getattr(self.s, "_ui_route_runtime", None)
        runtime = factory() if callable(factory) else None
        coordinates = getattr(runtime, "coordinates", None)
        generation = getattr(getattr(coordinates, "snapshot", None), "generation", None)
        # The real OS stamp is the observation's user-input generation. The
        # service may increment input_resume_generation later, while dispatching
        # this very same observed stamp; that bookkeeping is not a new event.
        return self._route_context()[:-1], last_input_stamp(), generation

    def _observation_current(self, checkpoint):
        # The result/menu was already classified. Only provenance is checked
        # here; an actuator guard must not capture or OCR the same screen again.
        self.s.check()
        return (getattr(self.s.window, "is_foreground", lambda: True)()
                and checkpoint == self._observation_checkpoint())

    def remember_field(self):
        """Called only after the owning route has observed the current HUD."""
        acknowledge_popup_return(self.s)
        self._known_field = self._observation_checkpoint()

    def consume_known_field(self):
        checkpoint = getattr(self, "_known_field", None)
        self._known_field = None
        return checkpoint is not None and self._observation_current(checkpoint)

    def _fixed_target(self, role):
        return self.s._ui_route_runtime().prepared_ref("snack.fixed", role)

    def stock(self):
        def read():
            self.s.check()
            value = bag_stock(self.s.connector.inventory())
            self.s.check()
            return value
        return self._retry_observation(read, "간식 재고 조회 미확인")

    def reconcile_pending(self):
        if self.s.store.value.get("snack_cleanup"):
            return self._resume_cleanup()
        pending = self.s.store.value.get("snack_pending")
        if not pending:
            return
        require(isinstance(pending, dict) and pending.get("action") in ("use", "craft") and
                isinstance(pending.get("before"), dict), "이전 간식 소비 기록 형식 확인 필요 · 자동 재전송 없음")
        if pending["action"] == "use":
            # Obsolete bag-use contract from the first 15 build. Archive the
            # uncertainty, never claim consumption or replay its old clicks.
            # This explicit new run still waits two minutes for its first B.
            self.s.store.retire_legacy_snack_use()
            self.s.status("이전 간식 가방 사용 미확인 기록 보존 · 기존 클릭 재전송 없음 · 새 실행은 2분 후 B 시도")
            return
        before = pending["before"]
        quantity = validate_quantity(pending.get("quantity", BATCH))
        # Resume the existing result route, not a second crafting request.
        # Bonus output and concurrent inventory updates are not failures.
        after = self.wait_craft_delta(before)
        delta = after[SNACK]["total"] - before[SNACK]["total"]
        self.s.store.complete_snack_craft(after)
        self.craft_confirmed = True
        self.s.status(f"이전 간식 {quantity}회 제작 · 실제 {delta}개 획득 확인 · 같은 소비 입력 재전송 없음")
        return self._resume_cleanup()

    def text(self, image, region):
        if self.s.ocr is None:
            self.s.ocr = FocusedOCR()
        return self.s.ocr.text(image, self._region(region, image))

    def wait_screen(self, predicate, message, timeout=None):
        # The historical timeout argument is diagnostic only; a missing frame
        # is not evidence that this route or its ingredients failed.
        return self._retry_observation(lambda: self._wait_screen(predicate, message), message)

    def _wait_screen(self, predicate, message):
        last_log = time.monotonic()
        while True:
            self.s.check()
            checkpoint = self._observation_checkpoint()
            image = self.s.window.capture()
            # A return popup can leave the old HUD/recipe readable behind it.
            # Drain that overlay before accepting this route checkpoint.
            if recover_fullscreen_popup(self.s, image):
                continue
            if predicate(image) and self._observation_current(checkpoint):
                acknowledge_popup_return(self.s)
                return image
            if time.monotonic()-last_log >= number("snack.observation.report_interval", 15):
                self.s.status(message + " · 같은 단계 재관측 · 선택/제작 재전송 없음")
                last_log = time.monotonic()
            self.s.sleep(number("snack.observation.poll_interval", 1))

    def tape(self, actions, *, rule_key=None, quantity=None):
        if quantity is not None:
            validate_quantity(quantity)
        craft_tape = self._geometry_value("CRAFT_TAPE")
        selecting = actions == craft_tape
        if selecting:
            self._recipe_tape_completed = False
            self._recipe_owner = None
        if selecting:
            if not self.consume_known_field():
                if recover_fullscreen_popup(self.s):
                    raise ObservationUnavailable("간식 선택 전 팝업 전환 · 현재 기본화면/제작 메뉴 재관측")
            rule_key = rule_key or "snack.select.fixed"
        with self.s.coordinate_sequence():
            actions = route(rule_key, actions) if rule_key else actions
            # Data-only, bounded actuator vocabulary. Never dispatch an imported
            # function/attribute from a rule file, nor allow a consuming action.
            for step in actions:
                require(isinstance(step, (list, tuple)) and len(step) == 2,
                        "간식 선택 규칙 단계 형식 오류")
                kind, target = step
                valid = ((kind == "key" and target == "K") or
                         (kind == "snack_text" and type(target) is str and target.isascii() and
                          target.isdecimal() and bool(target.strip("0"))) or
                         (kind == "click" and isinstance(target, (list, tuple)) and len(target) == 4 and
                          all(type(v) in (int, float) and 0 <= v <= 1 for v in target) and
                          target[0] < target[2] and target[1] < target[3]))
                require(valid, "간식 선택 규칙에 지원하지 않는 입력이 있습니다.")
            if quantity is not None:
                # The saved count tape's ten is the historical default, not a
                # second user setting. Keep its coordinates/order and bind the
                # amount selected from this request and the current bag stock.
                actions = tuple((kind, str(quantity) if kind == "snack_text" else target)
                                for kind, target in actions)
            for index, (kind, value) in enumerate(actions):
                if kind == "click":
                    value = self._fixed_target(rule_key+"."+str(index))
                sent = self.s.input(kind, value)
                if sent is False:
                    raise InputInterrupted("간식 선택 입력 보류 · 같은 단계 확인 필요")
            self.s.coordinate_step()
        if selecting and actions:
            final_kind, final_target = actions[-1]
            self._recipe_tape_completed = (final_kind == craft_tape[-1][0] and
                isinstance(final_target, (list,tuple)) and tuple(final_target) == tuple(craft_tape[-1][1]))

    def validate_layout(self):
        require(not getattr(self.s, "_half_window", lambda: False)(),
                "현재 창모드의 여행자 간식 제작 완료 경로는 미수집입니다. 추가 제작 입력 없이 중단합니다.")

    def open_recipe(self):
        while True:
            try:
                # The field proof can predate inventory/craftability reads;
                # attendance/Stella may arrive during those reads. Reuse the
                # known HUD, but check the popup boundary before the first K.
                if getattr(self, "_known_field", None) is not None:
                    if recover_fullscreen_popup(self.s):
                        self._known_field = None
                        raise ObservationUnavailable("간식 시작 전 팝업 전환 · 이전 기본화면 확인 갱신")
                return self._open_recipe()
            except (InputInterrupted, ObservationUnavailable) as exc:
                self.s.status("간식 선택 경로 방해 · 현재 메뉴에서 복구 · " + str(exc))
                if self._recover_recipe_selection():
                    return

    def _recover_recipe_selection(self):
        """Exceptional selection recovery, never an OCR gate on normal tape."""
        def known(image):
            if not getattr(self.s.window, "is_foreground", lambda: True)():
                return False
            if self.recipe(image):
                self._recipe_owner = self._route_context()
                return "recipe"
            if self.s.ocr.field_ready(image):
                self.remember_field()
                return "field"
            if self.owned_crafting_menu(image):
                return "menu"
            return False
        if self.s.ocr is None:
            self.s.ocr = FocusedOCR()
        selected = {}
        def remember(image):
            selected["state"] = known(image)
            return selected["state"]
        self.wait_screen(remember, "간식 선택 복구 · 현재 제작 메뉴/기본화면 재확인")
        if selected["state"] == "menu":
            self.close_owned()
            self.remember_field()
        return selected["state"] == "recipe"

    def _open_recipe(self):
        self.tape(self._geometry_value("CRAFT_TAPE"))
        if getattr(self, "_recipe_tape_completed", False):
            self._recipe_owner = self._route_context()

    def set_batch_count(self, quantity=BATCH):
        validate_quantity(quantity)
        while True:
            try:
                return self._set_batch_count(quantity)
            except (InputInterrupted, ObservationUnavailable) as exc:
                self.s.status("간식 수량 입력 방해 · 제작 전 현재 메뉴 복구 · " + str(exc))
                if not self._recover_recipe_selection():
                    self.open_recipe()

    def _set_batch_count(self, quantity):
        self.tape((("click", self._geometry_value("CRAFT_COUNT")), ("snack_text", str(quantity)),
                   ("click", self._geometry_value("COUNT_BLUR"))),
                  rule_key="snack.count.fixed", quantity=quantity)
        self._batch_owner = self._route_context()

    def craft_target(self):
        return self._fixed_target("snack.craft")

    def result_target(self):
        return self._fixed_target("snack.result.confirm")

    def owned_crafting_menu(self, image):
        return self.recipe(image) or "제작" in normalized(self.text(image, "ocr.snack.menu"))

    def close_owned(self, timeout=None):
        return self._retry_observation(self._close_owned, "간식 제작 메뉴 닫기 재확인")

    def _close_owned(self):
        # Only after our exact detail/result was verified. Never normalize an
        # arbitrary startup screen, and never press Escape on a visible HUD.
        last_escape = float("-inf")
        previous = getattr(self, "_cleanup_observation", None)
        self._cleanup_observation = None
        while True:
            self.s.check()
            if previous is not None and self._observation_current(previous[1]):
                state, checkpoint = previous
                previous = None
                if state == "field":
                    acknowledge_popup_return(self.s)
                    return
                owned = state == "menu"
            else:
                previous = None
                checkpoint = self._observation_checkpoint()
                image = self.s.window.capture()
                if not getattr(self.s.window, "is_foreground", lambda: True)():
                    self.s.sleep(1)
                    continue
                if recover_fullscreen_popup(self.s, image):
                    continue
                if self.s.ocr.field_ready(image):
                    if self._observation_current(checkpoint):
                        acknowledge_popup_return(self.s)
                        return
                    self.s.sleep(1)
                    continue
                owned = self.owned_crafting_menu(image)
            if not getattr(self.s.window, "is_foreground", lambda: True)():
                self.s.sleep(1)
                continue
            if owned and time.monotonic() - last_escape >= number("snack.cleanup.escape_interval", 4):
                try:
                    sent = self.s.input("key", "ESC", guard=lambda: self._observation_current(checkpoint),
                                        skip_if_guard_fails=True, wait_for_idle=False)
                except InputInterrupted:
                    sent = False
                if sent is not False:
                    acknowledge_popup_return(self.s)
                    last_escape = time.monotonic()
                    self.s.status("간식 제작 메뉴 닫기 · 기본화면을 확인한 뒤 같은 어비스로 복귀")
                    self.s.coordinate_step()
                    continue
            self.s.sleep(1)

    def recipe(self, image):
        if (getattr(image,"size",None) and self._recipe_owner is not None and self._recipe_owner == self._route_context() and
                getattr(self.s.window,"is_foreground",lambda:True)()):
            from .adaptive_life import _green_caption
            from .processing_recovery import heading_visible
            button, materials = self._recipe_regions(image)
            if _green_caption(image,button) and heading_visible(image,materials):
                return True
        return (normalized(SNACK) in normalized(self.text(image, "ocr.snack.recipe.title")) and
                any(normalized(label) in normalized(self.text(image, "ocr.snack.recipe.button"))
                    for label in value("snack.labels.craft", ["제작하기"])))

    def batch_ready(self, image):
        # The authored selection/count tape supplies the value. OCR must not
        # re-authorize the amount that we just entered (R06/S01).
        return (getattr(self, "_batch_owner", None) == self._route_context() and
                getattr(self.s.window, "is_foreground", lambda: True)())

    def owns_craft_result(self):
        pending = self.s.store.value.get("snack_pending") or {}
        return self.craft_confirmed or pending.get("action") == "craft"

    def result_ready(self, image):
        """Owned snack result + actual green ACK, never a generic confirm dialog."""
        if not getattr(self.s.window, "is_foreground", lambda: True)():
            return False
        if not getattr(image, "size", None):
            return False
        import numpy as np
        pixels = np.asarray(image.crop(crop_bounds(self._region("ocr.snack.result.body", image), image.size)).convert("RGB")).astype("int16")
        red, green, blue = pixels[:, :, 0], pixels[:, :, 1], pixels[:, :, 2]
        capsule = (green > number("snack.result.green_min",100)) & (
            green > red * number("snack.result.red_ratio",1.3)) & (
            green > blue * number("snack.result.blue_ratio",1.05))
        if capsule.mean() < number("snack.result.minimum_fraction",.60):
            return False
        if self.owns_craft_result():
            return True  # Same craft + result-button pixels; OCR is auxiliary.
        item = normalized(self.text(image, "ocr.snack.result.item"))
        if normalized(SNACK) not in item and (item or not self.craft_confirmed):
            return False
        title = normalized(self.text(image, "ocr.snack.result.title"))
        item_matches = normalized(SNACK) in item
        title_matches = any(normalized(label) in title for label in value("snack.labels.result", ["제작완료"]))
        # For an unowned screen these labels are auxiliary classification;
        # the owned crafting path above never waits for these OCR captions.
        if self.craft_confirmed and (item_matches or title_matches):
            return True
        if not item_matches:
            return False
        if title_matches:
            return True  # A pointer may hide the footer caption.
        footer = normalized(self.text(image, "ocr.snack.result.footer"))
        return footer.casefold() in [normalized(label).casefold() for label in
            value("snack.labels.confirm", ["확인", "space확인"])]

    def finish_craft_result(self, timeout=None):
        """Finish the existing non-consuming ACK route, then return to the HUD.

        The pending craft owns its result; stock is not an exact gate. Recovery
        never repeats the crafting request or expires because OCR took too long.
        """
        return self._retry_observation(self._finish_craft_result, "간식 완료창 정리 재확인")

    def _finish_craft_result(self):
        if self.s.ocr is None:
            self.s.ocr = FocusedOCR()
        start = time.monotonic()
        recipe_since = None
        last_click = float("-inf")
        acknowledged = False
        last_log = float("-inf")
        known_result = getattr(self, "_result_observation", None)
        self._result_observation = None
        self._cleanup_observation = None
        while True:
            self.s.check()
            now = time.monotonic()
            if known_result is not None and self._observation_current(known_result[0]):
                checkpoint, _ = known_result
                ready = True
            else:
                known_result = None
                checkpoint = self._observation_checkpoint()
                image = self.s.window.capture()
                if recover_fullscreen_popup(self.s, image):
                    recipe_since = None
                    continue
                ready = self.result_ready(image)
            if not getattr(self.s.window, "is_foreground", lambda: True)():
                self.s.sleep(1)
                continue
            if ready:
                recipe_since = None
                known_result = checkpoint, now
                retry_seconds = number("snack.result.retry_seconds", self._geometry_value("CRAFT_RESULT_RETRY_SECONDS"))
                if now - last_click >= retry_seconds:
                    try:
                        sent = self.s.input("click", self.result_target(),
                            guard=lambda: self._observation_current(checkpoint),
                            skip_if_guard_fails=True, wait_for_idle=False)
                    except InputInterrupted:
                        sent = False  # Only pre-input focus/visibility deferral.
                    known_result = None
                    if sent is not False:
                        acknowledge_popup_return(self.s)
                        acknowledged = True
                        last_click = time.monotonic()
                        self.s.status("간식 제작 완료 확인 클릭 · 닫힘 재확인 (추가 제작 없음)")
                        # The observed ready button needs only the shared input
                        # cadence, not a second stable timer or post-ACK poll.
                        self.s.coordinate_step()
                        continue
            else:
                if self.s.ocr.field_ready(image):
                    if self._observation_current(checkpoint):
                        acknowledge_popup_return(self.s)
                        self._cleanup_observation = "field", checkpoint
                        self.s.status("간식 제작 완료 확인 후 기본화면 확인 · 제작 메뉴 탐색/닫기 없이 복귀")
                        return  # Manual acknowledgement may have already closed it.
                    self.s.sleep(1)
                    continue
                if self.recipe(image):
                    if acknowledged and self._observation_current(checkpoint):
                        acknowledge_popup_return(self.s)
                        self._cleanup_observation = "menu", checkpoint
                        return
                    if recipe_since is None:
                        recipe_since = now
                    # Allow an already-dismissed receipt on a later explicit
                    # run, without treating the pre-result recipe as departure.
                    if now - start >= 5 and now - recipe_since >= 2:
                        if self._observation_current(checkpoint):
                            acknowledge_popup_return(self.s)
                            self._cleanup_observation = "menu", checkpoint
                            return
                        recipe_since = None
                else:
                    recipe_since = None
                if now - last_log >= 10:
                    self.s.status("간식 제작 결과 정리 · 완료창/확인 버튼 재관측 중 · 추가 제작 없음")
                    last_log = now
            self.s.sleep(1)

    def _resume_cleanup(self):
        cleanup = self.s.store.value.get("snack_cleanup")
        require(isinstance(cleanup, dict) and cleanup.get("action") == "craft" and
                cleanup.get("phase") == "result_cleanup" and
                type(cleanup.get("quantity")) is int and cleanup["quantity"] > 0 and
                isinstance(cleanup.get("after"), dict), "간식 제작 완료창 정리 기록 확인 필요 · 추가 제작 없음")
        self.craft_confirmed = True
        self.s.status("확정된 간식 제작의 완료창 정리 · 제작 입력 재전송 없음")
        # User-confirmed normal transition is ACK -> field directly. The
        # following method checks HUD first; recipe is only an owned fallback.
        self.finish_craft_result()
        self.close_owned()
        self.s.store.clear_snack_cleanup()
        return cleanup["after"]

    def craft(self, quantity=BATCH):
        requested = validate_quantity(quantity)
        if self.s.store.value.get("snack_cleanup"):
            return self._resume_cleanup()
        if self.s.store.value.get("snack_pending"):
            # An interrupted/missing reply is still the same consuming request.
            # Reconcile its result instead of opening and crafting a second batch.
            after = self.reconcile_pending()
            if after is not None:
                return after
        self.craft_confirmed = False
        before = self.stock()
        quantity = validate_materials(before, requested)
        if quantity == 0:
            self.s.status("여행자 간식 재료 부족 · 잠금 없는 달걀·고기로 제작 가능한 수량 0 · 제작 입력 없이 보유 재고 사용")
            return before
        self.s.connector.require_command("get_craftable_items")
        if not self._retry_observation(self._craftable, "간식 제작 가능 목록 재확인"):
            self.s.status("여행자 간식 제작 불가/미해금 확인 · 제작 입력 없이 보유 재고 사용")
            return before
        # Never reuse the fixed consuming tape for an unsupported layout.
        self._retry_observation(self.validate_layout, "간식 준비 좌표 문맥 재확인")
        self.s.status(f"던전 밖 · 여행자 간식 {quantity}회 제작 (요청 {requested}회) · 잠금 없는 보유 재료만 사용")
        self.open_recipe()
        self.set_batch_count(quantity)
        # Reserve at the actuator's mouse-down boundary, not before geometry,
        # focus or state validation. Unsent input must not strand a receipt.
        while not self.s.store.value.get("snack_pending"):
            try:
                with self.s.coordinate_sequence():
                    target = self.craft_target()
                    sent = self.s.input("click", target, guard=lambda: self.batch_ready(None),
                        skip_if_guard_fails=True,
                        before_send=lambda: self.s.store.set_snack_pending(dict(action="craft", before=before, quantity=quantity)))
                    if sent is False:
                        raise InputInterrupted("간식 제작 입력 미전송 · 현재 메뉴 재확인")
                    self.s.coordinate_step()
                break
            except (InputInterrupted, ObservationUnavailable) as exc:
                if self.s.store.value.get("snack_pending"):
                    self.s.status("간식 제작 입력 결과 미확인 · 기존 제작 기록으로 결과 확인만 계속 · " + str(exc))
                    break
                if not self._recover_recipe_selection():
                    self.open_recipe()
                self.set_batch_count(quantity)
        after = self.wait_craft_delta(before)
        self.s.store.complete_snack_craft(after)
        self.craft_confirmed = True
        self._resume_cleanup()
        gained = after[SNACK]["total"] - before[SNACK]["total"]
        self.s.status(f"여행자 간식 {quantity}회 제작 · 실제 {gained}개 획득 확인 · 가방 {after[SNACK]['total']}개 · 기존 작업으로 복귀")
        return after

    def _craftable(self):
        view = self.s.connector.read("get_craftable_items", SNACK)
        _observed(isinstance(view, dict), "여행자 간식 제작 기능 미확인")
        if view.get("craftingUnlocked") is False:
            return False
        _observed(isinstance(view.get("items"), list), "여행자 간식 제작 가능 목록 미확인")
        rows = [r for r in view["items"] if isinstance(r, dict) and r.get("DisplayName") == SNACK]
        _observed(len(rows) == 1, "여행자 간식 제작 가능 항목 미확인")
        if rows[0].get("Craftable") is False:
            return False
        _observed(view.get("craftingUnlocked") is True and rows[0].get("Craftable") is True,
                  "여행자 간식 제작 가능 상태 미확인")
        return True

    def craft_ten(self):
        """Compatibility for the historical default ten-request route."""
        return self.craft(BATCH)

    def wait_craft_delta(self, before):
        return self._retry_observation(lambda: self._wait_craft_delta(before), "간식 제작 결과 재확인")

    def _wait_craft_delta(self, before):
        # Quantity is bookkeeping, not an exact success contract. Keep the
        # pending receipt and attempt its known ACK route as soon as visible.
        while True:
            self.s.check()
            self.s.sleep(3)
            after = self.stock()
            delta = after[SNACK]["total"] - before[SNACK]["total"]
            if delta > 0:
                return after
            checkpoint = self._observation_checkpoint()
            image = self.s.window.capture()
            if recover_fullscreen_popup(self.s, image):
                continue
            if self.result_ready(image):
                if self._observation_current(checkpoint):
                    acknowledge_popup_return(self.s)
                    self._result_observation = checkpoint, time.monotonic()
                self.s.status("간식 완료창 확인 · 수량 일치 검사 없이 확인/복귀 진행")
                return after
