"""Pure candidate rectangles from the bounded 2026-09-26 source survey.

Parents/results are physical, top-left-origin CLIENT rectangles. ``scale`` is
UI-unit to physical-pixel; no OS origin, DPI multiplier, settings read or input
authority is added here. These candidates do not assert a live scene binding.
See docs/SOURCE-FORMULA-MAPPING-20260926.md for evidence and remaining inputs.
"""
from __future__ import annotations

from dataclasses import dataclass
import math
from types import MappingProxyType
from typing import Mapping

from .ui_layout import LayoutError, Rect, RectTransform, child_rect


@dataclass(frozen=True)
class SourceMenuCandidate:
    role: str
    rect: Rect
    metadata: Mapping[str, object]

    def __post_init__(self):
        object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))


def _number(value, label, *, positive=False, nonnegative=False):
    try:
        valid = (type(value) in (int, float) and math.isfinite(value)
                 and (not positive or value > 0)
                 and (not nonnegative or value >= 0))
    except OverflowError:
        valid = False
    if not valid:
        raise LayoutError(f"invalid {label}")
    return float(value)


def _inputs(parent, scale):
    if not isinstance(parent, Rect):
        raise LayoutError("explicit physical parent Rect required")
    return _number(scale, "UI scale", positive=True)


def _candidate(role, rect, *, source, clip, actuator=True, assumptions=(),
               supplied_parents=(), unresolved=(), **parameters):
    visible = rect.intersection(clip) if clip is not None else None
    return SourceMenuCandidate(role, rect, {
        "candidate": True,
        "runtime_click_approved": False,
        "coordinate_space": "physical-client",
        "source": source,
        "provenance": "source-with-experimental-assumption" if assumptions else "source-with-explicit-parent",
        "actuator": actuator,
        "assumptions": tuple(assumptions),
        "supplied_parents": tuple(supplied_parents),
        "unresolved_inputs": tuple(unresolved),
        "fully_visible": clip is not None and clip.contains(rect),
        "visible_bounds": visible.bounds if visible is not None else None,
        **parameters,
    })


def dungeon_list_difficulties(safe_area: Rect, *, scale: float):
    """Landscape DungeonListUI magnet and its three serialized child boxes.

    The caller selects this LANDSCAPE source family; orientation is not guessed.
    Child content sizes are serialized candidates, not freshly measured text.
    """
    s = _inputs(safe_area, scale)
    parent = Rect(safe_area.left + 40*s, safe_area.top + 184*s, 680*s, 46*s)
    source = "DungeonListUI/DungeonListUI_DifficultySelector"
    role = "dungeon.list.difficulty.parent"
    result = {role: _candidate(role, parent, source=source, clip=safe_area,
                              actuator=False, supplied_parents=("safe_area",),
                              source_orientation="landscape", scale=s)}
    for index, (name, center, width) in enumerate((
        ("normal", 41.6, 75.2), ("hard", 135.6, 96.8),
        ("very_hard", 264.82, 145.64),
    )):
        role = f"dungeon.list.difficulty.{name}"
        rect = Rect(parent.left + (center-width/2)*s, parent.top, width*s, 46*s)
        result[role] = _candidate(
            role, rect, source=source, clip=parent.intersection(safe_area),
            supplied_parents=("safe_area",), index=index, scale=s,
            source_orientation="landscape",
            assumptions=("serialized child text/content sizes remain unchanged",),
        )
    return MappingProxyType(result)


def dungeon_entry_footer(safe_area: Rect, *, scale: float,
                         entry_button_parent: Rect | None = None):
    """Landscape footer/magnet; CTA needs its final driven wrapper rectangle.

    The source wrapper has a driven zero width. Its final width/placement must
    be supplied; absence returns only non-actuator parent candidates, not an
    invented full-width entry button.
    """
    s = _inputs(safe_area, scale)
    footer = child_rect(safe_area, RectTransform(
        (0, 0), (1, 0), (.5, 0), (376, 0), (-808, 416)), s)
    bottom = child_rect(footer, RectTransform(
        (.5, 0), (.5, 0), (.5, 0), (0, 0), (720, 172)), s)
    source = "GoddessDungeonEntryUI/Landscape/Footer"
    unresolved = ("entry_button_parent",) if entry_button_parent is None else ()
    result = {}
    for role, rect in (("dungeon.entry.footer", footer),
                       ("dungeon.entry.bottom_info", bottom)):
        result[role] = _candidate(role, rect, source=source, clip=safe_area,
                                  actuator=False, supplied_parents=("safe_area",),
                                  unresolved=unresolved, source_orientation="landscape", scale=s)
    if entry_button_parent is not None:
        _inputs(entry_button_parent, s)
        rect = child_rect(entry_button_parent, RectTransform(
            (0, 0), (1, 0), (.5, 0), (0, 0), (0, 80)), s)
        role = "dungeon.entry.confirm"
        result[role] = _candidate(
            role, rect, source=source + "/GoddessEntryButton/ButtonBase_CTAWithPrice",
            clip=entry_button_parent.intersection(safe_area),
            supplied_parents=("safe_area", "entry_button_parent"),
            source_orientation="landscape", scale=s,
        )
    return MappingProxyType(result)


def mission_accept(bottom_area: Rect, *, scale: float, is_subitem: bool):
    """Mission/submission AcceptButton, separate from currency text displays."""
    s = _inputs(bottom_area, scale)
    if type(is_subitem) is not bool:
        raise LayoutError("is_subitem must be explicit bool")
    offset = 0 if is_subitem else 10
    rect = Rect(bottom_area.right-168*s, bottom_area.bottom-(64+offset)*s, 168*s, 64*s)
    role = "dungeon.mission.accept"
    return MappingProxyType({role: _candidate(
        role, rect, source="GoddessEntryMissionList/BottomArea/Button_Accept",
        clip=bottom_area, supplied_parents=("bottom_area",),
        is_subitem=is_subitem, scale=s,
    )})


def gathering_category(area2: Rect, *, scale: float, is_portrait: bool,
                       index: int, scroll_offset: float, flow_left: float | None = None):
    """fn1331/1332 category grid, using explicit SPLIT-AREA orientation.

    ``scroll_offset`` is nonnegative UI units; ``flow_left`` is a physical
    client x. Missing flow_left uses area2.left as a marked EXPERIMENTAL parent
    alignment, not a recovered client default. No total item count is inferred.
    """
    s = _inputs(area2, scale)
    if type(is_portrait) is not bool:
        raise LayoutError("split-area is_portrait must be explicit bool")
    if type(index) is not int or index < 0:
        raise LayoutError("index must be a nonnegative integer")
    scroll = _number(scroll_offset, "scroll offset", nonnegative=True)
    assumptions = ()
    supplied = ("area2",)
    if flow_left is None:
        left = area2.left
        assumptions = ("experimental flow_left=area2.left; horizontal alignment not recovered",)
    else:
        left = _number(flow_left, "physical flow left")
        supplied += ("flow_left",)
    width_units, bottom_units = (680, 0) if is_portrait else (1368, 40)
    viewport = Rect(left, area2.top, width_units*s, area2.height-bottom_units*s)
    columns = (width_units+8)//(164+8)
    row, column = divmod(index, columns)
    rect = Rect(left+column*172*s, area2.top+(row*252-scroll)*s, 164*s, 244*s)
    role = "gather.category"
    candidate = _candidate(
        role, rect, source="ProfileUI_LivingSkill.mmml.xml:1331/1332",
        clip=viewport.intersection(area2), assumptions=assumptions,
        supplied_parents=supplied, index=index, columns=columns,
        split_area_is_portrait=is_portrait, scroll_offset_ui=scroll,
        viewport_bounds=viewport.bounds, scale=s,
    )
    return MappingProxyType({role: candidate})
