"""Named, pure life-scene geometry from the saved source-layout calculators.

No window capture, game access, input, catalog sorting or normalized-coordinate
fallback is performed here. A caller supplies the current ordered IDs and
physical scroll offsets; a wheel delta/focus index is not a scroll offset.
The returned rectangles are calculation candidates, never scene/input authority.
"""
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
import hashlib
import json
import math
import re
from types import MappingProxyType

from .source_list_layouts import facility_card, recipe_card
from .source_menu_layouts import gathering_category
from .source_result_layouts import common_result_confirm
from .ui_layout import CanvasContext, LayoutError, Rect
from .ui_rule_engine import calculate_scene


REVISION = "source-life-routes-20260929-native-wheel-clamp"
_COMPILED_SHA = "b772d6c351958e614170ae608855f3fd1afd4d506198dd090723af820aa238fa"
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,95}\Z")
_MISSING_ROLES = {
    "processing.register.legacy_prefab": ("CraftFacilityUI_RecipeItemDetail_Landscape active parent magnet",),
    "snack.result": ("separate TitledScrollUI_Craft_Result caller/parent binding",),
}


class LifeGeometryUnavailable(LayoutError):
    """Named missing inputs are not permission to substitute legacy points."""

    def __init__(self, scene, missing_inputs):
        self.scene = scene
        self.missing_inputs = tuple(missing_inputs)
        super().__init__(scene + ": missing " + "; ".join(self.missing_inputs))


@dataclass(frozen=True)
class LifeRouteGeometry:
    rect: Rect
    ocr_rect: Rect | None
    visible: bool
    enabled: bool
    metadata: Mapping[str, object]
    visible_rect: Rect | None


def _required(state, scene, keys):
    missing = tuple(key for key in keys if key not in state or state[key] is None)
    if missing:
        raise LifeGeometryUnavailable(scene, missing)


def _number(value, name):
    if type(value) not in (int, float) or not math.isfinite(value):
        raise LayoutError(name + " must be finite physical client pixels")
    return float(value)


def _identity(state, scene, *names):
    _required(state, scene, names)
    if any(type(state[name]) is not str or not _ID.fullmatch(state[name]) for name in names):
        raise LayoutError("explicit semantic life identity required: " + ", ".join(names))


def _flags(state, scene, *names):
    _required(state, scene, names)
    if any(type(state[name]) is not bool for name in names):
        raise LayoutError("life scene flags must be explicit booleans")
    if any(state[name] is not True for name in names):
        raise LifeGeometryUnavailable(scene, tuple(name + "=true" for name in names if not state[name]))


def _ordered_ids(state, scene):
    _required(state, scene, ("ordered_ids", "scroll_x", "scroll_y"))
    values = state["ordered_ids"]
    if (type(values) not in (tuple, list) or not values or len(values) > 512
            or any(type(value) is not str or not _ID.fullmatch(value) for value in values)
            or len(set(values)) != len(values)):
        raise LayoutError("ordered_ids must be a bounded nonempty sequence of unique semantic IDs")
    return tuple(values), _number(state["scroll_x"], "scroll_x"), _number(state["scroll_y"], "scroll_y")


def _parent(state, context):
    parent = state.get("parent_rect")
    if parent is not None and (not isinstance(parent, Rect) or not context.root_rect.contains(parent)):
        raise LayoutError("parent_rect must be a resolved physical client rectangle")
    return parent


def _safe(state, context):
    safe = state.get("safe_area", context.safe_rect)
    if not isinstance(safe, Rect) or not context.root_rect.contains(safe):
        raise LayoutError("safe_area must be a resolved physical client rectangle")
    return safe


def _portrait_progress(context, state, safe, scene):
    """911 portrait content block followed by generated queue flow."""
    _required(state, scene, ("queue_count", "move_visible", "upgrade_visible"))
    count = state["queue_count"]
    if type(count) is not int or not 1 <= count <= 7:
        raise LayoutError("portrait topinfo needs actual generated queue_count1..7")
    if any(type(state[key]) is not bool for key in ("move_visible", "upgrade_visible")):
        raise LayoutError("portrait topinfo button visibility must be explicit")
    s = context.scale
    queue_width = safe.width - 200 * s
    columns = math.floor((queue_width + 10 * s) / (110 * s))
    if columns < 1:
        raise LifeGeometryUnavailable(scene, ("portrait queue complete column",))
    rows = math.ceil(count / columns)
    progress_height = max(100 * s, (rows * 100 + (rows - 1) * 10) * s)
    description_height = state.get("description_height")
    assumptions = ["portrait progress flow uses default horizontal direction and topinfo gap0"]
    if description_height is None:
        description_height = 48 * s
        assumptions.append("facility description takes its source maximum height48")
    else:
        description_height = _number(description_height, "description_height")
        if not 34 * s <= description_height <= 48 * s:
            raise LayoutError("description_height must satisfy source34..48 bound")
    button_height = 48 * s if state["move_visible"] or state["upgrade_visible"] else 0
    top = safe.top + (158 + 20 + 24 + 16 + 16 + 32) * s + description_height + button_height
    metadata = dict(topinfo_anchor_basis="source911-content-plus-generated-progress",
        queue_count=count, queue_columns=columns, move_visible=state["move_visible"],
        upgrade_visible=state["upgrade_visible"], description_height=description_height,
        branch_basis=state.get("branch_basis", "caller-current-branches"),
        anchor_candidate_assumptions=tuple(assumptions))
    return top, progress_height, metadata


def _geometry(rect, safe, *, parent=None, enabled=True, metadata):
    visible_rect = rect.intersection(safe)
    if visible_rect is not None and parent is not None:
        visible_rect = visible_rect.intersection(parent)
    x, y = rect.center
    visible = bool(visible_rect is not None
                   and visible_rect.left <= x < visible_rect.right
                   and visible_rect.top <= y < visible_rect.bottom)
    meta = dict(metadata, runtime_click_approved=False,
                visible_meaning="original center inside client/parent clip; not current scene authority")
    return LifeRouteGeometry(rect, visible_rect, visible, enabled,
                             MappingProxyType(meta), visible_rect)


def _list_scene(context, scene, state, safe):
    ids, sx, sy = _ordered_ids(state, scene)
    parent = _parent(state, context)
    landscape = context.orientation == "landscape"
    if scene == "processing.recipes":
        _required(state, scene, ("facility_id", "subtract_padding"))
        if type(state["facility_id"]) is not str or not _ID.fullmatch(state["facility_id"]):
            raise LayoutError("facility_id must be the current owned facility ID")
        if type(state["subtract_padding"]) is not bool:
            raise LayoutError("subtract_padding must explicitly select the unresolved flow-padding candidate")
    anchor_metadata = {}
    category_bottom, topinfo_bottom = state.get("category_bottom"), state.get("topinfo_bottom")
    if scene == "processing.recipes" and parent is None:
        if landscape and category_bottom is None:
            # FacilityUI911 category class: top96/height62 inside SafeArea.
            category_bottom = safe.top + 158 * context.scale
            anchor_metadata["category_anchor_basis"] = "source911-top96-height62"
        elif not landscape and topinfo_bottom is None:
            top, height, anchor_metadata = _portrait_progress(context, state, safe, scene)
            topinfo_bottom = top + height
    prefix = "processing.facility." if scene == "processing.facilities" else "processing.recipe."
    records = {}
    for index, item_id in enumerate(ids):
        if scene == "processing.facilities":
            layout = facility_card(context, index, len(ids), is_landscape=landscape,
                scroll_x=sx, scroll_y=sy, parent_rect=parent, safe_area=safe)
        else:
            layout = recipe_card(context, index, len(ids), is_landscape=landscape,
                category_bottom=category_bottom, topinfo_bottom=topinfo_bottom,
                scroll_x=sx, scroll_y=sy, subtract_padding=state["subtract_padding"],
                parent_rect=parent, safe_area=safe)
        metadata = dict(layout.metadata, scene=scene, item_id=item_id, item_index=index,
            ordered_ids=ids, scroll_offset=(sx, sy), columns=layout.columns,
            filler_count=layout.filler_count, parent_rect=layout.parent_rect.bounds,
            order_basis=state.get("order_basis", "caller-current-array"),
            enabled_meaning="source card declaration only; caller guards current item enablement")
        metadata.update(anchor_metadata)
        if scene == "processing.recipes":
            metadata["facility_id"] = state["facility_id"]
            if not landscape and "description_height" not in state:
                # The content-height description is34..48UI. Keep list/scroll
                # geometry, but prepare an interior shared by every height.
                metadata["source_click_interior"] = Rect(layout.rect.left, layout.rect.top,
                    layout.rect.width, layout.rect.height-14*context.scale).bounds
        records[prefix + item_id] = _geometry(layout.rect, safe,
            parent=layout.parent_rect, metadata=metadata)
    if "focus_id" in state:
        _processing_scroll_record(context, scene, state, safe, records, prefix)
    return records


def _processing_scroll_record(context, scene, state, safe, records, prefix):
    """Source content bounds -> reset/seek plan, never an observed click box.

    Every selection first saturates the list at its top. This removes the
    unknown previous offset and MMML911 scrollToRecipeIndex auto-focus.
    Windows raw wheel events clamp to[-1,1] before ScrollSpeed30 is applied.
    Each prepared notch is therefore a distinct event, not a larger raw delta.
    """
    focus = state["focus_id"]
    role = prefix + str(focus)
    if role not in records:
        raise LayoutError("processing focus_id is absent from current ordered list")
    item = records[role]
    sx, sy = item.metadata["scroll_offset"]
    if sx != 0 or sy < 0:
        raise LayoutError("processing list needs a nonnegative vertical scroll offset")
    x1, y1, x2, y2 = item.metadata["parent_rect"]
    parent = Rect(x1, y1, x2-x1, y2-y1)
    viewport = parent.intersection(safe)
    if viewport is None or viewport.height <= 0:
        raise LifeGeometryUnavailable(scene, ("visible processing scroll viewport",))
    s = context.scale
    bottom_pad = (60 if scene == "processing.facilities" else 32) * s
    content_height = max(value.rect.bottom + sy for value in records.values()) - parent.top + bottom_pad
    maximum = max(0.0, content_height - parent.height)
    step = _number(state.get("wheel_step_ui", 30), "wheel_step_ui") * s
    if step <= 0:
        raise LayoutError("positive source wheel step required")
    phase = state.get("scroll_phase", "seek")
    if phase not in ("reset", "seek"):
        raise LayoutError("processing scroll phase must be reset or seek")
    # Saturate farther than the entire content extent, in bounded wheel events.
    budget = math.ceil(maximum / step) + 2 if maximum else 0
    remaining = state.get("reset_remaining_notches", budget)
    if type(remaining) is not int or not 0 <= remaining <= budget:
        raise LayoutError("invalid processing top-reset budget")
    if phase == "reset":
        count = min(5, remaining)
        delta, next_offset = 120 * count, 0.0
        left = remaining - count
        next_phase = "reset" if left else "seek"
    else:
        if sy > maximum + .001:
            raise LayoutError("processing scroll offset exceeds current content clamp")
        center = item.rect.center[1] + sy
        desired = max(0.0, min(maximum, center - viewport.center[1]))
        desired = min(maximum, max(0.0, round(desired / step) * step))
        gap = desired - sy
        count = min(5, math.ceil(abs(gap) / step - 1e-8))
        delta = (-120 if gap > 0 else 120) * count
        next_offset = min(maximum, max(0.0, sy + (step * count if gap > 0 else -step * count)))
        left, next_phase = 0, "seek"
    # An unknown old offset must not publish selectable item targets.
    if phase == "reset":
        for key, value in tuple(records.items()):
            records[key] = LifeRouteGeometry(value.rect, value.ocr_rect, value.visible, False,
                                             value.metadata, value.visible_rect)
    meta = dict(item.metadata, family="processing-source-scroll", focus_id=focus,
        viewport_bounds=viewport.bounds, content_height=content_height,
        max_scroll_y=maximum, next_wheel_delta=delta, next_scroll_y=next_offset,
        next_scroll_phase=next_phase, reset_remaining_notches=left,
        scroll_phase=phase, source_scroll_offset_known=phase == "seek",
        wheel_step_ui=step / s, wheel_input_mode="notches",
        wheel_event_clamp=(-1, 1), wheel_boundary_clamp=True, wheel_inertia=False,
        wheel_source="scroll-input-20260929:UnityPlayer8534a2/8534c3;GameAssembly72647a0",
        candidate_assumptions=tuple(item.metadata.get("candidate_assumptions", ())) + (
            "saturating top reset removes prior/manual/scrollToRecipeIndex offset without lost wheel events",))
    records[scene + ".scroll"] = _geometry(viewport, safe, metadata=meta)


def _tabs_scene(context, state, safe):
    scene = "processing.tabs"
    _required(state, scene, ("ordered_ids",))
    ids = state["ordered_ids"]
    if (type(ids) not in (tuple, list) or not 1 <= len(ids) <= 8
            or any(type(key) is not str or not _ID.fullmatch(key) for key in ids) or len(set(ids)) != len(ids)):
        raise LayoutError("current ordered crafting tab IDs required")
    s = context.scale
    landscape = context.orientation == "landscape"
    if landscape:
        # 905 grid col1 width144, rows1/4 candidate exclusive span (bottom100).
        # 212 outer width112/right8/top96/bottom32; inner centered/down/gap48.
        available = safe.height - (100 + 96 + 32) * s
        length = (len(ids) * 100 + (len(ids) - 1) * 48) * s
        left, top = safe.left + 24 * s, safe.top + 96 * s + (available - length) / 2
        stride = (0, 148 * s)
    else:
        # 905 portrait nav grid row3/4, starts132 above bottom. 212 top12,
        # height100/left32/right32; inner centered/right/gap8.
        available = safe.width - 96 * s
        length = (len(ids) * 112 + (len(ids) - 1) * 8) * s
        left, top = safe.left + 32 * s + (available - length) / 2, safe.bottom - 120 * s
        stride = (120 * s, 0)
    if length > available:
        raise LifeGeometryUnavailable(scene, ("MainTabButtonBar shrink layout for current tab count",))
    meta = dict(scene=scene, family="crafting-main-tabs-mmml905", source_functions=(905, 212, 166, 205),
        compiled_source_sha256=_COMPILED_SHA, ordered_ids=tuple(ids),
        order_basis=state.get("order_basis", "caller-current-array"),
        candidate_assumptions=("source grid span uses exclusive end line", "MainTabButtonBar fills its grid parent"))
    return {"processing.tab." + item_id: _geometry(
        Rect(left + index * stride[0], top + index * stride[1], 112 * s, 100 * s), safe,
        metadata=dict(meta, item_id=item_id, item_index=index)) for index, item_id in enumerate(ids)}


def _gather_categories_scene(context, state, safe):
    """1354 category content parent ->1331/1332 existing source grid.

    This completes the source-root input missing from gathering_category. It
    does not equate the later LivingSkill popup with the nearby-location link.
    """
    scene = "gather.categories"
    ids, sx, sy = _ordered_ids(state, scene)
    s = context.scale
    parent = _parent(state, context)
    assumptions = []
    if parent is None:
        # 1354: rows96/20/1fr/20/128, col3 content. Landscape columns
        #144/42/1fr/42 and row3/5; portrait1fr/0/680/1fr and row3.
        if context.orientation == "landscape":
            parent = Rect(safe.left + 186 * s, safe.top + 116 * s,
                          safe.width - 228 * s, safe.height - 244 * s)
        else:
            parent = Rect(safe.center[0] - 340 * s, safe.top + 116 * s,
                          680 * s, safe.height - 264 * s)
        assumptions.extend(("ProfileUI1354 grid fills SafeArea", "grid span uses exclusive end line"))
    split_portrait = state.get("area2_is_portrait")
    if split_portrait is None:
        split_portrait = context.orientation == "portrait"
        assumptions.append("SplitView.Area2 orientation follows Screen for this initial candidate")
    if type(split_portrait) is not bool:
        raise LayoutError("area2_is_portrait must be a boolean")
    flow_left = state.get("flow_left")
    if flow_left is None:
        flow_left = parent.left
        assumptions.append("unset category vscroll horizontal alignment starts at content parent left")
    else:
        flow_left = _number(flow_left, "flow_left")
    result = {}
    for index, item_id in enumerate(ids):
        item = gathering_category(parent, scale=s, is_portrait=split_portrait,
            index=index, scroll_offset=sy / s, flow_left=flow_left - sx)["gather.category"]
        meta = dict(item.metadata, scene=scene, source_functions=(1354, 1331, 1332),
            compiled_source_sha256=_COMPILED_SHA, ordered_ids=ids, item_id=item_id,
            order_basis=state.get("order_basis", "caller-current-array"),
            candidate_assumptions=tuple(assumptions), parent_rect=parent.bounds,
            area2_is_portrait=split_portrait, scroll_offset=(sx, sy))
        x1, y1, x2, y2 = item.metadata["viewport_bounds"]
        viewport = Rect(x1, y1, x2 - x1, y2 - y1)
        clip = parent.intersection(viewport)
        result["gather.category." + item_id] = _geometry(item.rect, safe,
            parent=clip or parent, metadata=meta)
    return result


def _gather_tabs_scene(context, state, safe):
    """Profile1354 -> MainTabButtonBar212 -> button166, not crafting905."""
    scene = "gather.tabs"
    _required(state, scene, ("ordered_ids",))
    ids = state["ordered_ids"]
    if (type(ids) not in (tuple, list) or not 1 <= len(ids) <= 6
            or any(type(key) is not str or not _ID.fullmatch(key) for key in ids)
            or len(set(ids)) != len(ids)):
        raise LayoutError("current ordered profile tab IDs required")
    s = context.scale
    if context.orientation == "landscape":
        available = safe.height - (128 + 32 + 32) * s
        length = (len(ids) * 100 + (len(ids) - 1) * 48) * s
        left = safe.left + 24 * s
        top = safe.top + 32 * s + (available - length) / 2
        stride = (0, 148 * s)
    else:
        available = safe.width - 64 * s
        length = (len(ids) * 112 + (len(ids) - 1) * 8) * s
        left = safe.left + 32 * s + (available - length) / 2
        top = safe.bottom - 116 * s
        stride = (120 * s, 0)
    if length > available:
        raise LifeGeometryUnavailable(scene, ("Profile MainTabButtonBar shrink branch",))
    meta = dict(scene=scene, family="profile-tabs-mmml1354", source_functions=(1354, 212, 166),
        compiled_source_sha256=_COMPILED_SHA, ordered_ids=tuple(ids),
        order_basis=state.get("order_basis", "caller-current-array"),
        candidate_assumptions=("Profile grid fills SafeArea with exclusive end-line spans",
            "portrait navigation spans the safe width", "MainTabButtonBar fills grid parent"))
    return {"gather.tab." + item: _geometry(Rect(left + i * stride[0], top + i * stride[1],
            112 * s, 100 * s), safe, metadata=dict(meta, item_id=item, item_index=i))
        for i, item in enumerate(ids)}


def _gather_items_scene(context, state, safe):
    """LivingSkillPopup magnet -> ContentsArea -> VerticalLayoutRuleSlot.

    ScrollSpeed30 is serialized source. UnityPlayer clamps each raw wheel
    event to[-1,1]; ScrollSlot applies that value in local anchoredPosition
    units with immediate boundary clamp and resets inertia before/after it.
    The resolver owns the entire offset plan, including its UI scale.
    """
    scene = "gather.items"
    ids, sx, sy = _ordered_ids(state, scene)
    _identity(state, scene, "category_id")
    if sx != 0 or sy < 0:
        raise LayoutError("LivingSkill list has only a nonnegative vertical offset")
    s = context.scale
    panel = _parent(state, context)
    assumptions = ["open-snap magnet is active; popup not user-dragged/minimized",
        "VerticalLayoutRuleSlot starts first row at TopPadding250 (serialized sample258 is driven)",
        "all generated skill rows retain source height112 and spacing8"]
    if panel is None:
        h = (780 if context.orientation == "landscape" else 1080) * s
        x = safe.right - 760 * s if context.orientation == "landscape" else safe.center[0] - 360 * s
        panel = Rect(x, safe.bottom - h, 720 * s, h)
        assumptions.append("native panel adopts active landscape720x780/portrait720x1080 magnet")
    viewport = Rect(panel.left + 40 * s, panel.top + 56 * s, panel.width - 80 * s, panel.height - 56 * s)
    content_h = (250 + len(ids) * 112 + (len(ids) - 1) * 8 + 32) * s
    max_scroll = max(0, content_h - viewport.height)
    if sy > max_scroll + .001:
        raise LayoutError("skill-list scroll exceeds source content/viewport clamp")
    focus = state.get("focus_id")
    notches, focus_offset = 0, sy
    if focus is not None:
        if focus not in ids:
            raise LayoutError("focus_id is not in current skill array")
        desired = min(max_scroll, ids.index(focus) * 120 * s)
        notches = math.ceil(max(0, desired - sy) / (30 * s) - 1e-9)
        focus_offset = min(max_scroll, sy + notches * 30 * s)
        assumptions.append("each prepared wheel notch is delivered as a separate raw input event")
    meta = dict(scene=scene, family="LivingSkillPopup_MinimizablePanel", source_functions=(1354, 1331, 1332),
        source_prefab_sha256="32e16f7584ad4f16645f390ca89b28826aecd625cfb151e55bcbac9650dc10ca",
        row_rect_path_id=3240766241316615196, layout_rule_path_id=7333860139572784234,
        scroll_slot_path_id=5395472555899215594, ordered_ids=ids, category_id=state["category_id"],
        order_basis=state.get("order_basis", "caller-current-array"), scroll_offset=(sx, sy),
        parent_rect=panel.bounds, viewport_bounds=viewport.bounds, max_scroll_y=max_scroll,
        focus_wheel_delta=-120 * notches, focus_scroll_y=focus_offset,
        next_wheel_delta=-120 * min(5, notches),
        next_scroll_y=min(max_scroll, sy + min(5, notches) * 30 * s),
        wheel_input_mode="notches", wheel_step_ui=30, wheel_event_clamp=(-1, 1),
        wheel_boundary_clamp=True, wheel_inertia=False,
        wheel_source="scroll-input-20260929:UnityPlayer8534a2/8534c3;GameAssembly72647a0",
        candidate_assumptions=tuple(assumptions), row_action="LivingSkillElementControl.OnClick")
    result = {"gather.item." + item: _geometry(Rect(viewport.left,
        viewport.top + (250 + index * 120) * s - sy, viewport.width, 112 * s), safe,
        parent=viewport, metadata=dict(meta, item_id=item, item_index=index)) for index, item in enumerate(ids)}
    clip = viewport.intersection(safe)
    if clip is None:
        raise LifeGeometryUnavailable(scene, ("visible skill viewport",))
    # A small authored interior of this source scroll receiver, not an OCR row.
    result["gather.list.scroll"] = _geometry(Rect(clip.center[0] - 12 * s,
        clip.center[1] - 12 * s, 24 * s, 24 * s), safe, parent=viewport, metadata=meta)
    return result


def _gather_travel_scene(context, state, safe):
    """DetailCardUILayout.BottomArea.LeftButtons.Button_HowToGet.

    This is not HowToGetSelectUI500 or LivingSkillUISlot. The item's caller
    supplies its finished card parent when available. Otherwise reuse the78
    individually measured content heights and the cross-profile measured
    parent correction. Native preferredHeight/caller placement is NOT claimed
    recovered: the independent prefab's minimum and y+20 are not this parent.
    """
    scene = "gather.travel"
    _identity(state, scene, "node_id")
    _flags(state, scene, "detail_confirmed", "requirement_met", "link_confirmed", "settled")
    _required(state, scene, ("scroll_y", "link_kind"))
    kind = "fishing" if state["node_id"].startswith("fishing-") else "nearby"
    if state["link_kind"] != kind:
        raise LayoutError("gather detail identity/link kind mismatch")
    sy = _number(state["scroll_y"], "scroll_y")
    if sy < 0:
        raise LayoutError("card scroll_y must be nonnegative")
    s = context.scale
    card = _parent(state, context)
    calibration = {}
    assumptions = ["Gatherable/Fishery card calls DetailCardUILayout Button_HowToGet",
        "BottomArea is final visible card block; no comparison/equipment modal buttons",
        "visible Korean action has at least27 source units of caption before trailing help icon"]
    if card is None:
        from .gather_geometry import prepared_detail_parent
        card, calibration = prepared_detail_parent(context, state["node_id"])
        assumptions.append("measured78-half content heights at identical576-UI text width;11-wide cross-check")
    # Description464 centered in root576; LeftButtons left0; link28 centered
    # in BottomArea34. Clip to first27 units, deliberately excluding ? glyph.
    rect = Rect(card.center[0] - 232 * s, card.bottom - (55 + 31) * s - sy, 27 * s, 28 * s)
    meta = dict(scene=scene, family="DetailCardUILayout.Button_HowToGet",
        source_prefab_sha256="680ea7a33e181e7f0cec9a18d03312fac92e17df6d29b74200f276dfd208d36d",
        source_root_sha256="0bf75f8e3656af62ece7cd6d99614619945572100c8c37850c74529857db5f9c",
        source_metadata_sha256="cdeed3914cc11325e9771047bdbe4bb0acf1733f16f01783418f753c2a6fde8a",
        button_rect_path_id=-1548736690294852245, node_id=state["node_id"], link_kind=kind,
        parent_rect=card.bounds, scroll_offset=(0, sy), candidate_assumptions=tuple(assumptions),
        parent_basis="caller-supplied-source-parent" if "parent_rect" in state else "measured-content-height-and-parent-correction",
        geometry_kind="source-derived first27 caption interior; not recovered text preferred width")
    meta.update(calibration)
    # Per-node observation must follow the same parent as its actuator; the
    # global life observation's first-item detail must not stand in for it.
    output = {scene: _geometry(rect, safe, parent=card, metadata=meta)}
    observations = {
        "gather.detail.title": Rect(card.center[0]-232*s, card.top+24*s, 464*s, 159*s),
        "gather.detail.requirements": Rect(card.center[0]-232*s, card.top+55*s, 464*s, 190*s),
        "gather.detail.link": Rect(card.center[0]-240*s, card.bottom-94*s-sy, 480*s, 50*s),
    }
    for role, region in observations.items():
        observation_meta = dict(meta, actuator=False, observation_only=True)
        if role == "gather.detail.requirements":
            from .gather_geometry import prepared_requirement_exclusions
            observation_meta["normalized_exclusions"] = prepared_requirement_exclusions(context, card, scroll_y=sy)
        output[role] = _geometry(region, safe, parent=card, enabled=False,
            metadata=observation_meta)
    return output


def _collect_scene(context, state, safe, processing_rules):
    scene = "processing.collect"
    keys = ("queue_count", "facility_ready", "full_safe_area", "collect_enabled")
    _required(state, scene, keys)
    if type(state["full_safe_area"]) is not bool:
        raise LayoutError("full_safe_area must be an explicit boolean")
    if context.orientation == "portrait":
        # This is the distinct MMML911 SafeArea flow, not the landscape
        # reviewed binding whose historical state contract says full_safe_area.
        _flags(state, scene, "facility_ready", "collect_enabled")
        top, height, meta = _portrait_progress(context, state, safe, scene)
        if state["move_visible"]:
            raise LifeGeometryUnavailable(scene, ("owned local portrait overview without remote movement",))
        s = context.scale
        rect = Rect(safe.left + 32 * s, top + (100 - 46) * s / 2, 120 * s, 46 * s)
        if "description_height" not in state:
            meta["source_click_interior"] = Rect(rect.left, rect.top, rect.width, rect.height-14*s).bounds
        meta.update(scene=scene, family="facility-portrait-mmml911", source_functions=(911, 912, 205),
            compiled_source_sha256=_COMPILED_SHA,
            queue_count_meaning="actual generated visible slots including empty slots; not work count",
            candidate_assumptions=meta["anchor_candidate_assumptions"] + (
                "GetAllButton default vertical placement centers height46 in portrait parent100",),
            progress_height=height, geometry_parent="safe-area",
            actual_full_safe_area=safe == context.root_rect,
            landscape_full_safe_area_binding_applied=False)
        return {"processing.collect_all": _geometry(rect, safe, metadata=meta)}
    # The saved landscape program anchors progressHolder to Screen.bottom,
    # not SafeArea.top. A top-only inset leaves this source equation unchanged.
    # Other insets have not been reviewed for this family and remain blocked.
    root = context.root_rect
    if any(area.left != root.left or area.right != root.right or area.bottom != root.bottom
           for area in (safe, context.safe_rect)):
        raise LifeGeometryUnavailable(scene, ("landscape queue with unchanged Screen left/right/bottom",))
    # Reuse the source program already consumed by MultiProcessingPort. Do not
    # duplicate its queue-count/row-height equation in another coordinate owner.
    if processing_rules is None:
        from .ui_rule_pack import load_rule_pack
        processing_rules = load_rule_pack()
    entry = processing_rules.scene_for("processing.collect_all")
    flags = {key: state[key] for key in keys if key != "queue_count"}
    parameters = entry.validate_inputs(flags, {"queue_count": state["queue_count"]})
    result = calculate_scene(entry.scene, context, state=flags, parameters=parameters)
    item = result.elements[entry.target_key]
    if item.status != "resolved" or not item.enabled or item.rect is None:
        raise LifeGeometryUnavailable(scene, ("enabled owned local GetAllButton",))
    meta = dict(scene=scene, source_functions=(911, 912),
        source_program_signature=entry.scene.definition_signature,
        source_input_signature=result.input_signature, queue_count=state["queue_count"],
        queue_count_meaning="actual generated visible slots including empty slots; not work count",
        candidate_assumptions=(),
        geometry_parent="screen", safe_area_support="full-or-top-inset-only",
        actual_full_safe_area=safe == root and context.safe_rect == root,
        reviewed_binding_required_state={"full_safe_area": True},
        reviewed_binding_scope="original full-area binding; top inset is source-wrapper support only",
        queue_bounds=result.elements["queueBounds"].rect.bounds,
        queue_cell=result.elements["queueCell"].rect.bounds,
        queue_stride=result.elements["queueStride"].rect.bounds)
    return {"processing.collect_all": _geometry(item.rect, safe, metadata=meta)}


def _register_scene(context, state, safe):
    """AlteringPanelUI889 -> BottomSheet209 -> ButtonPreset_CTA163.

    This is the current saved MMML family, not the older landscape prefab's
    StartButton (height66/min-width164 with a CounterSlot sibling). Mixing that
    prefab with MMML889's frame would manufacture a layout never in the source.
    """
    scene = "processing.register"
    _identity(state, scene, "facility_id", "recipe_id")
    _flags(state, scene, "local_detail", "register_enabled", "settled")
    _required(state, scene, ("scroll_y",))
    sy = _number(state["scroll_y"], "scroll_y")
    if sy < 0:
        raise LayoutError("recipe-detail scroll_y must be a nonnegative physical offset")
    fits = state.get("content_fits")
    if fits is not None and type(fits) is not bool:
        raise LayoutError("content_fits must be boolean or explicitly unknown")
    assumptions = ["active recipe popup uses saved AlteringPanelUI889 caller",
                   "unset vscroll/flow dimensions fill their BottomSheet parent",
                   "TweenShowSlot in grid column3 fills its track"]
    # 209: width720, landscape right32 (centerInLandscape=false,
    # use2025Layout=false), otherwise centered; SafeArea205 is the parent.
    s = context.scale
    width = 720 * s
    left = safe.right - (720 + 32) * s if context.orientation == "landscape" else safe.center[0] - width / 2
    # 889: useContentSize=true/usePadding=false; vscroll padding54/0/32/0.
    # CTA placeholder is LAST in the content, so earlier dynamic text/ingredient
    # heights cancel when this content-sized sheet fits without clipping. The
    # caller can supply its computed unscrolled content bottom for overflow.
    content_bottom = state.get("content_bottom")
    if content_bottom is None:
        if fits is False:
            raise LifeGeometryUnavailable(scene, ("content_bottom for overflowing recipe-detail content",))
        content_bottom = safe.bottom - 32 * s
        if fits is None:
            assumptions.append("recipe-detail content fits its content-sized bottom sheet")
    else:
        content_bottom = _number(content_bottom, "content_bottom")
    # 889 PC1241..1354: inner flow padding0/48 and final height80 CTA;
    # PC1423..1568: grid56/16/1fr, confirm in column3. Source width=552.
    rect = Rect(left + (48 + 56 + 16) * s, content_bottom - 80 * s - sy,
                (720 - 2 * 48 - 56 - 16) * s, 80 * s)
    meta = dict(scene=scene, family="altering-panel-mmml889", source_functions=(889, 209, 205, 163),
        compiled_source_sha256=_COMPILED_SHA,
        source_body_sha256="ef072bcd3fcdffadb0170724b718db5b91a05fd8d1f27d7909264a6119d7456e",
        source_frame_sha256="369e688ccd40ff8528417677242c3a6270425ed9535b4324c6b277a1ada3aef6",
        facility_id=state["facility_id"], recipe_id=state["recipe_id"],
        sheet_horizontal_bounds=(left, left + width), unscrolled_content_bottom=content_bottom,
        scroll_y=sy, content_fits_state="known" if fits is not None else "candidate-unknown-assumed-fitting",
        candidate_assumptions=tuple(assumptions), animation="settled-only; BottomSheet0.6s",
        role_scope="local recipe registration only; never remote travel")
    return {scene: _geometry(rect, safe, metadata=meta)}


def _travel_scene(context, state, safe):
    """Source-guaranteed interior across bounded intrinsic text dimensions.

    The whole button cannot be sized without its font's intrinsic text width.
    Its minimum width and the description height interval ARE in the source;
    their intersection gives an interior, not an invented exact outer box.
    """
    scene = "processing.facility.travel"
    _identity(state, scene, "facility_id")
    _flags(state, scene, "remote", "move_visible")
    _required(state, scene, ("upgrade_visible",))
    if type(state["upgrade_visible"]) is not bool:
        raise LayoutError("upgrade_visible must be an explicit source branch")
    s = context.scale
    landscape = context.orientation == "landscape"
    # 911 body=(left64,top120) landscape; (left0,top158) portrait.
    # content padding left32/top20; title24, two gaps16; description34..48.
    # 908 button height48; content text120..220 plus left/right padding30.
    left = safe.left + (64 + 32 if landscape else 32) * s
    # 911's topinfo is554 wide in landscape. In portrait its content reserves
    #238+32 on the right for the illustration, not the landscape padding32.
    flow_width = 490 * s if landscape else safe.width - (32 + 238 + 32) * s
    min_width, max_width, gap = 180 * s, 280 * s, 12 * s
    width = min_width
    horizontal_bounds = (left, left)
    shrink_bounds = (1.0, 1.0)
    assumptions = ["FacilityUI topinfo is at its source default flow start",
                   "unset horizontal flow alignment is left"]
    if state["upgrade_visible"]:
        # Flow.UpdateSize0xc112fe4..0xc1130cb applies max(0,min(1,W/S)).
        # UnityMMMLWindow.OnSetScale -> MMMLElementBase.SetPivotAndScale
        #0xaa78072..0xaa78096 scales BOTH axes of the whole flow uniformly.
        # For intrinsic upgrade U, move M and gap G, the move spans
        # [k*(U+G), min(W,U+G+M)], k=min(1,W/(U+G+M)). The extrema below
        # cover every U/M in180..280, including the490-wide landscape parent.
        if flow_width <= 0:
            raise LifeGeometryUnavailable(scene, (
                "upgrade+move hoverflow=shrink: nonpositive source content width",))
        shrink_bounds = (min(1.0, flow_width / (2 * max_width + gap)),
                         min(1.0, flow_width / (2 * min_width + gap)))
        horizontal_bounds = (
            left + (min_width + gap) * min(1.0, flow_width / (min_width + gap + max_width)),
            left + (max_width + gap) * min(1.0, flow_width / (max_width + gap + min_width)))
        right = left + min(flow_width, 2 * min_width + gap)
        left = horizontal_bounds[1]
        width = right - left
        assumptions.append("source flow uses its default top-left shrink pivot")
    else:
        assumptions.append("one move button does not shrink below source minimum")
    body_top = safe.top + (120 if landscape else 158) * s
    earliest_top = body_top + (20 + 24 + 16 + 34 + 16) * s
    latest_top = body_top + (20 + 24 + 16 + 48 + 16) * s
    height = earliest_top + 48 * s * shrink_bounds[0] - latest_top
    if width <= 0 or height <= 0:
        raise LifeGeometryUnavailable(scene, (
            "upgrade+move hoverflow=shrink: no common interior across source text/description bounds",))
    rect = Rect(left, latest_top, width, height)
    meta = dict(scene=scene, family="facility-move-mmml911-908", source_functions=(911, 908, 205),
        compiled_source_sha256=_COMPILED_SHA,
        source_body_sha256="0818fabfbf22ce08bbe5be17bd33b44ffe2c29a1812b4dd3654c851157d98a51",
        source_button_sha256="df9a421dc2542fdc04c0957dc683adae365858a382c04341439d707592386f97",
        facility_id=state["facility_id"], upgrade_visible=state["upgrade_visible"],
        branch_basis=state.get("branch_basis", "caller-current-branches"),
        geometry_kind="intersection of source-bounded button interiors, not exact outer button",
        description_height_bounds=(34 * s, 48 * s), button_width_bounds=(180 * s, 280 * s),
        button_top_bounds=(earliest_top, latest_top),
        button_left_bounds=horizontal_bounds, button_flow_width=flow_width,
        preceding_button_width_bounds=((min_width, max_width) if state["upgrade_visible"] else None),
        button_gap=gap if state["upgrade_visible"] else 0,
        uniform_shrink_scale_bounds=shrink_bounds,
        shrink_source_rvas=("0xc112fe4", "0xc113020", "0xc1130cb", "0xa9fbe6b", "0xaa78072"),
        candidate_assumptions=tuple(assumptions),
        role_scope="owned remote facility overview only; no recipe CTA travel")
    return {scene: _geometry(rect, safe, metadata=meta)}


def _travel_confirm_scene(context, state, safe):
    """202 two-button cancel/confirm grid in210's bottom-position popup.

    The source popup can also be centered. The owned town-movement question
    initially selects its bottom-position candidate; that caller selection is
    not disguised as a recovered runtime uiData.popupPositionType value.
    """
    scene = "processing.travel.confirm"
    _identity(state, scene, "facility_id")
    _flags(state, scene, "owned_travel_request", "question_confirmed", "settled")
    position = state.get("popup_position", "bottom")
    if position != "bottom":
        raise LifeGeometryUnavailable(scene, ("center-position popup content height/parent",))
    s = context.scale
    # 210 width640/padding56,42,42,42 and default bottom40; 202 last
    # grid height80, columns1fr/8/1fr. Dynamic body/title heights cancel.
    rect = Rect(safe.center[0] + 4 * s, safe.bottom - (40 + 42 + 80) * s,
                (640 - 2 * 42 - 8) * s / 2, 80 * s)
    meta = dict(scene=scene, family="common-cancel-confirm-mmml202", source_functions=(202, 210, 163, 205),
        compiled_source_sha256=_COMPILED_SHA,
        source_body_sha256="43f410fff98f16d179fe14b916a7c9191295ecd1fc2762b3e928d7bcd21621d1",
        source_frame_sha256="9852e958579be1f66c6cfb7ce358799631ba5b7d7b010ea47447925593b832fa",
        facility_id=state["facility_id"], ordered_roles=("cancel", "confirm"),
        popup_position_state="candidate-bottom" if "popup_position" not in state else "caller-supplied-bottom",
        candidate_assumptions=("owned facility town-movement question uses CommonPopup_CancelConfirm202",
            "popupPositionType uses bottom placement", "unset horizontal popup placement is centered"),
        animation="settled-only; CommonFrame_Popup0.3s",
        role_scope="owned exact town-movement question only; no generic/cost/storage confirmation")
    return {scene: _geometry(rect, safe, metadata=meta)}


def resolve_life_scene(context: CanvasContext, scene: str, *, state: Mapping,
                       processing_rules=None, result_rules=None) -> Mapping[str, LifeRouteGeometry]:
    """Return a whole named geometry batch for one declared life scene.

    ``processing.facilities``: ordered_ids, scroll_x/y; optional physical
    parent_rect and safe_area. ``processing.recipes`` adds facility_id and an
    explicit subtract_padding candidate; absent a resolved parent, also needs
    category_bottom (landscape) or topinfo_bottom (portrait). Landscape can
    derive category.bottom=SafeArea.top+158 from911; portrait may derive its
    topinfo using actual queue_count and move_visible/upgrade_visible branches.
    Unknown description height takes its source maximum48 as a named candidate.
    ``processing.tabs`` accepts current ordered_ids for905/212/166. Order may
    differ from the catalog after filtering. No default scroll=0 is invented.

    ``processing.collect`` requires actual queue_count, facility_ready,
    full_safe_area and collect_enabled. ``processing.result`` requires settled
    bool, with optional safe_area or already-padded content_parent (not both).
    Result/list parent assumptions remain in metadata for candidate testing.

    ``gather.tabs`` accepts ordered profile IDs. ``gather.categories`` binds
    Profile1354 to1331/1332. ``gather.items`` accepts current ordered_ids,
    category_id, physical scroll_x/y and optional focus_id. Its source scroll
    record includes the next bounded wheel input and resulting candidate
    offset; distinct raw wheel events use the recovered[-1,1] clamp and
    ScrollSpeed30, not the magnitude of one combined Windows delta.
    ``gather.travel`` accepts node_id, link_kind, physical scroll_y and four
    current detail/requirement/link/settled flags. Optional parent_rect is the
    finished source card, never an observed panel box. Without it, the source
    minimum-content centered parent is an explicitly experimental candidate.

    ``processing.register`` uses MMML889/209/163: facility_id, recipe_id,
    local_detail/register_enabled/settled=true and physical scroll_y. Unknown
    content_fits is an explicit fitting-content trial assumption; overflowing
    content requires its computed content_bottom. ``processing.facility.travel``
    requires facility_id, remote/move_visible=true and explicit upgrade_visible;
    its source-bounded safe interior avoids guessing intrinsic font widths.

    The old recipe-detail prefab's magnet is still not linked to this family.
    Scene identity, enabled captions, current recipe/materials, receipts and
    input authority remain the caller's existing checkpoint responsibility.
    """
    if not isinstance(context, CanvasContext) or not isinstance(state, Mapping):
        raise LayoutError("explicit CanvasContext and life scene state required")
    if type(scene) is not str:
        raise LayoutError("explicit named life scene required")
    if scene in _MISSING_ROLES:
        raise LifeGeometryUnavailable(scene, _MISSING_ROLES[scene])
    safe = _safe(state, context)
    if scene in ("processing.facilities", "processing.recipes"):
        records = _list_scene(context, scene, state, safe)
    elif scene == "processing.tabs":
        records = _tabs_scene(context, state, safe)
    elif scene == "gather.categories":
        records = _gather_categories_scene(context, state, safe)
    elif scene == "gather.tabs":
        records = _gather_tabs_scene(context, state, safe)
    elif scene == "gather.items":
        records = _gather_items_scene(context, state, safe)
    elif scene == "gather.travel":
        records = _gather_travel_scene(context, state, safe)
    elif scene == "processing.collect":
        records = _collect_scene(context, state, safe, processing_rules)
    elif scene == "processing.register":
        records = _register_scene(context, state, safe)
    elif scene == "processing.facility.travel":
        records = _travel_scene(context, state, safe)
    elif scene == "processing.travel.confirm":
        records = _travel_confirm_scene(context, state, safe)
    elif scene == "processing.storage_confirm":
        from .source_processing_storage import calculate_storage_geometry
        records = calculate_storage_geometry(context, state=state,
                                             enabled_roles=("processing.storage_confirm",))
    elif scene == "processing.result":
        _required(state, scene, ("settled",))
        if type(state["settled"]) is not bool:
            raise LayoutError("settled must be an explicit result-animation state")
        if not state["settled"]:
            return MappingProxyType({})
        layout = common_result_confirm(context, family="facility", safe_area=state.get("safe_area"),
            content_parent=state.get("content_parent"), rules=result_rules)
        records = {role: _geometry(rect, safe, parent=layout.content_parent,
                                  metadata=dict(layout.metadata, scene=scene))
                   for role, rect in layout.rects.items()}
    else:
        raise LifeGeometryUnavailable(scene, ("saved source formula for this named life scene",))
    # Stable geometry/state identity is available to the central-store publisher.
    # Positions are physical-client only; desktop movement is not a layout edit.
    fingerprint = hashlib.sha256(json.dumps({
        "revision": REVISION, "scene": scene, "client": (context.viewport.width, context.viewport.height),
        "scale": context.scale, "safe": safe.bounds,
        "records": {role: {"rect": item.rect.bounds, "visible": item.visible,
            "enabled": item.enabled, "metadata": dict(item.metadata)} for role, item in records.items()},
    }, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
    return MappingProxyType({role: LifeRouteGeometry(item.rect, item.ocr_rect, item.visible, item.enabled,
        MappingProxyType(dict(item.metadata, revision=REVISION, calculation_signature=fingerprint)),
        item.visible_rect) for role, item in records.items()})
