"""Pure, physical-client-pixel UI geometry; no OS, game or CLI operations.

The custom canvas formula and style boundaries come from the read-only client
analysis ``phase2/native-map/ui-scaling-semantics.md`` (2026-09-23). Constructor
profiles are *not* evidence of a running canvas: callers must supply its profile,
orientation, resolved parent geometry and active layout children. A calculated
rectangle never establishes screen identity, visibility through other windows,
button state, input authority, or permission to repeat a consuming operation.

Rectangles use top-left client pixels. Unity anchors/pivots use bottom-left Y;
positions and size deltas use UI units. Desktop origin is applied exactly once,
at the final projection. Windows DPI and the Mini's WebView zoom are irrelevant
when the caller has already obtained physical client-content dimensions.
"""
from __future__ import annotations

from dataclasses import dataclass
import math
from typing import Iterable


class LayoutError(ValueError):
    """Missing/ambiguous layout evidence or invalid geometry, never game state."""


def _number(value, name: str, *, positive=False, nonnegative=False) -> float:
    if isinstance(value, bool):
        raise LayoutError(f"{name} must be a finite number, not bool")
    try:
        result = float(value)
    except (TypeError, ValueError, OverflowError) as exc:
        raise LayoutError(f"{name} must be a finite number") from exc
    if not math.isfinite(result) or (positive and result <= 0) or (nonnegative and result < 0):
        raise LayoutError(f"invalid {name}: {value!r}")
    return result


def _pair(value, name: str, *, positive=False):
    if isinstance(value, (str, bytes, dict)):
        raise LayoutError(f"{name} needs a numeric pair")
    try:
        if len(value) != 2:
            raise LayoutError(f"{name} needs exactly two values")
        return tuple(_number(v, name, positive=positive) for v in value)
    except TypeError as exc:
        raise LayoutError(f"{name} needs exactly two values") from exc


def _nonempty(value, name):
    if not isinstance(value, str) or not value.strip():
        raise LayoutError(f"{name} must be explicit")
    return value


@dataclass(frozen=True)
class Rect:
    left: float
    top: float
    width: float
    height: float

    def __post_init__(self):
        for name in ("left", "top", "width", "height"):
            object.__setattr__(self, name, _number(getattr(self, name), name,
                                                positive=name in ("width", "height")))
        _number(self.right, "right")
        _number(self.bottom, "bottom")

    @property
    def right(self):
        return self.left + self.width

    @property
    def bottom(self):
        return self.top + self.height

    @property
    def center(self):
        return self.left + self.width / 2, self.top + self.height / 2

    @property
    def bounds(self):
        return self.left, self.top, self.right, self.bottom

    def contains(self, other: "Rect") -> bool:
        return (self.left <= other.left and self.top <= other.top
                and self.right >= other.right and self.bottom >= other.bottom)

    def intersection(self, other: "Rect") -> "Rect | None":
        # Preserve exact geometry when no clipping occurred. Reconstructing
        # width via (left + width) - left introduces floating cancellation and
        # would incorrectly report a fully visible fractional-pixel UI clipped.
        if other.contains(self):
            return self
        left, top = max(self.left, other.left), max(self.top, other.top)
        right, bottom = min(self.right, other.right), min(self.bottom, other.bottom)
        return Rect(left, top, right-left, bottom-top) if right > left and bottom > top else None

    def inset(self, amount: "Insets | float") -> "Rect":
        amount = amount if isinstance(amount, Insets) else Insets.uniform(amount)
        return Rect(self.left+amount.left, self.top+amount.top,
                    self.width-amount.left-amount.right, self.height-amount.top-amount.bottom)

    def translated(self, dx, dy) -> "Rect":
        return Rect(self.left+_number(dx, "dx"), self.top+_number(dy, "dy"), self.width, self.height)

    def pixel_bounds(self, *, inward=False):
        """Integer half-open crop bounds; inward=True retains only inside pixels."""
        lo, hi = (math.ceil, math.floor) if inward else (math.floor, math.ceil)
        result = lo(self.left), lo(self.top), hi(self.right), hi(self.bottom)
        if result[2] <= result[0] or result[3] <= result[1]:
            raise LayoutError("rectangle contains no usable pixel area")
        return result


@dataclass(frozen=True)
class Insets:
    left: float = 0
    top: float = 0
    right: float = 0
    bottom: float = 0

    def __post_init__(self):
        for name in ("left", "top", "right", "bottom"):
            object.__setattr__(self, name, _number(getattr(self, name), name, nonnegative=True))

    @classmethod
    def uniform(cls, amount):
        return cls(amount, amount, amount, amount)


@dataclass(frozen=True)
class Viewport:
    width: int
    height: int
    origin: tuple[float, float] = (0, 0)
    safe_area: Rect | None = None

    def __post_init__(self):
        # Pixel dimensions are integers; do not silently truncate logical pixels.
        for name in ("width", "height"):
            value = _number(getattr(self, name), name, positive=True)
            if value != int(value):
                raise LayoutError("physical client dimensions must be integral")
            object.__setattr__(self, name, int(value))
        origin = _pair(self.origin, "desktop origin")
        if any(v != int(v) for v in origin):
            raise LayoutError("physical desktop origin must be integral")
        object.__setattr__(self, "origin", tuple(int(v) for v in origin))
        if self.safe_area is not None:
            if not isinstance(self.safe_area, Rect) or not self.rect.contains(self.safe_area):
                raise LayoutError("safe area must be inside the physical client")

    @property
    def rect(self):
        return Rect(0, 0, self.width, self.height)

    @property
    def safe_rect(self):
        # A clipping boundary only, never a claim that game layout uses safeArea.
        return self.safe_area if self.safe_area is not None else self.rect

    def normalized_point(self, point, *, allow_edge=False):
        """Preserve round() projection; edge=True is for legacy inspection only."""
        x, y = _pair(point, "normalized point")
        if not all(0 <= v <= 1 if allow_edge else 0 <= v < 1 for v in (x, y)):
            raise LayoutError("normalized point is outside client")
        result = round(x*self.width), round(y*self.height)
        if allow_edge:
            return result
        return min(self.width-1, result[0]), min(self.height-1, result[1])

    def to_screen(self, point):
        x, y = _pair(point, "client point")
        if not (0 <= x < self.width and 0 <= y < self.height):
            raise LayoutError("client point is outside input bounds")
        result = x+self.origin[0], y+self.origin[1]
        return tuple(int(v) if v.is_integer() else v for v in result)

    def normalized_rect(self, region) -> Rect:
        try:
            x1, y1, x2, y2 = (_number(v, "normalized rectangle") for v in region)
        except (TypeError, ValueError) as exc:
            raise LayoutError("normalized rectangle needs four values") from exc
        if not (0 <= x1 < x2 <= 1 and 0 <= y1 < y2 <= 1):
            raise LayoutError("normalized rectangle is outside client")
        left, top, right, bottom = (round(v*extent) for v, extent in zip(
            (x1, y1, x2, y2), (self.width, self.height, self.width, self.height)))
        return Rect(left, top, right-left, bottom-top)

    def rect_to_normalized(self, rect: Rect):
        if not self.rect.contains(rect):
            raise LayoutError("rectangle is outside client")
        return rect.left/self.width, rect.top/self.height, rect.right/self.width, rect.bottom/self.height


def desired_scale_percent(style: str, viewport: Viewport, pc_percent=70, *, is_mobile_device=False):
    """Client GetDesiredScalePercent strict short-side boundaries (physical px)."""
    if (not isinstance(viewport, Viewport) or style not in ("pc", "mobile", "auto")
            or type(is_mobile_device) is not bool):
        raise LayoutError("style/mobile-device context must be explicit")
    if is_mobile_device or style == "mobile":
        return 100
    if style == "pc":
        if isinstance(pc_percent, bool) or pc_percent not in (70, 80, 90, 100):
            raise LayoutError("PC scale must be 70, 80, 90 or 100")
        return int(pc_percent)
    short = min(viewport.width, viewport.height)
    return 100 if short < 800 else 90 if short < 1100 else 80 if short < 1300 else 70


@dataclass(frozen=True)
class CanvasProfile:
    reference_width: float
    reference_height: float
    minimum_width: float
    provenance: str
    confirmed: bool = False

    def __post_init__(self):
        for name in ("reference_width", "reference_height", "minimum_width"):
            object.__setattr__(self, name, _number(getattr(self, name), name, positive=True))
        _nonempty(self.provenance, "profile provenance")
        if type(self.confirmed) is not bool:
            raise LayoutError("profile confirmation must be boolean")


def constructor_profile(orientation: str) -> CanvasProfile:
    """Static constructor defaults, intentionally NOT session-confirmed profiles."""
    if orientation == "portrait":
        return CanvasProfile(720, 1280, 591.1329956054688, "client-constructor-portrait", False)
    if orientation == "landscape":
        return CanvasProfile(1280, 720, 1280, "client-constructor-landscape", False)
    raise LayoutError("orientation must be explicitly portrait or landscape")


@dataclass(frozen=True)
class CanvasContext:
    viewport: Viewport
    orientation: str
    style: str
    percent: int
    profile: CanvasProfile
    scale: float
    style_scale_applied: bool

    def __post_init__(self):
        if (not isinstance(self.viewport, Viewport) or not isinstance(self.profile, CanvasProfile)
                or self.orientation not in ("portrait", "landscape")
                or self.style not in ("pc", "mobile", "auto")
                or isinstance(self.percent, bool) or self.percent not in (70, 80, 90, 100)
                or type(self.style_scale_applied) is not bool):
            raise LayoutError("unresolved canvas context")
        scale = _number(self.scale, "canvas scale", positive=True)
        inverse = 100/self.percent if self.style_scale_applied else 1
        width = self.profile.reference_width*inverse if self.style_scale_applied else self.profile.minimum_width
        expected = min(self.viewport.width/width, self.viewport.height/(self.profile.reference_height*inverse))
        if not math.isclose(scale, expected, rel_tol=1e-12, abs_tol=0):
            raise LayoutError("canvas scale disagrees with supplied profile")

    @property
    def root_rect(self):
        return self.viewport.rect

    @property
    def safe_rect(self):
        return self.viewport.safe_rect

    @property
    def confirmed(self):
        return self.profile.confirmed


def resolve_canvas(viewport: Viewport, style: str, pc_percent=70, *, orientation: str,
                   profile: CanvasProfile, apply_style_scale=True, is_mobile_device=False) -> CanvasContext:
    """Resolve screen-space/root canvas only; no inferred orientation/asset overrides.

    If style scale is applied, the native setter multiplies reference X/Y by
    100/percent AND replaces minimum width with the scaled reference X. Passing
    False models a supplied profile before that setter, not a second DPI scale.
    Constructor defaults may be calculated for analysis, but remain unconfirmed.
    """
    if orientation not in ("portrait", "landscape") or not isinstance(profile, CanvasProfile):
        raise LayoutError("explicit orientation and canvas profile required")
    if type(apply_style_scale) is not bool:
        raise LayoutError("style-application state must be explicit")
    percent = desired_scale_percent(style, viewport, pc_percent, is_mobile_device=is_mobile_device)
    inverse = 100 / percent if apply_style_scale else 1
    reference_height = profile.reference_height * inverse
    minimum_width = profile.reference_width * inverse if apply_style_scale else profile.minimum_width
    scale = _number(min(viewport.width/minimum_width, viewport.height/reference_height),
                    "canvas scale", positive=True)
    return CanvasContext(viewport, orientation, style, percent, profile, scale, apply_style_scale)


@dataclass(frozen=True)
class RectTransform:
    anchor_min: tuple[float, float]
    anchor_max: tuple[float, float]
    pivot: tuple[float, float]
    anchored_position: tuple[float, float]
    size_delta: tuple[float, float]
    local_scale: tuple[float, float] = (1, 1)
    rotation_degrees: float = 0

    def __post_init__(self):
        for name in ("anchor_min", "anchor_max", "pivot", "anchored_position", "size_delta", "local_scale"):
            object.__setattr__(self, name, _pair(getattr(self, name), name, positive=name == "local_scale"))
        for name in ("anchor_min", "anchor_max", "pivot"):
            if not all(0 <= v <= 1 for v in getattr(self, name)):
                raise LayoutError("anchors and pivot must be in [0,1]")
        if any(a > b for a, b in zip(self.anchor_min, self.anchor_max)):
            raise LayoutError("reversed anchors")
        if _number(self.rotation_degrees, "rotation") != 0:
            raise LayoutError("rotated transforms need an explicit corner projection")


def child_rect(parent: Rect, transform: RectTransform, scale=1.0) -> Rect:
    """Unity anchor/pivot algebra with resolved physical parent; no root guessing.

    scale is UI-unit -> physical-pixel for this parent (including known ancestor
    scales), scalar or (x,y). Local scale acts around the child's own pivot.
    A stretched size_delta may be negative; the final dimensions must be >0.
    """
    if not isinstance(parent, Rect) or not isinstance(transform, RectTransform):
        raise LayoutError("resolved parent rectangle and transform required")
    sx, sy = _pair(scale, "unit scale", positive=True) if isinstance(scale, (tuple, list)) else (
        _number(scale, "unit scale", positive=True),) * 2
    amin, amax, pivot = transform.anchor_min, transform.anchor_max, transform.pivot
    pos, delta, local = transform.anchored_position, transform.size_delta, transform.local_scale
    width = _number((amax[0]-amin[0])*parent.width+delta[0]*sx, "child width", positive=True)
    height = _number((amax[1]-amin[1])*parent.height+delta[1]*sy, "child height", positive=True)
    px = parent.left+(amin[0]+(amax[0]-amin[0])*pivot[0])*parent.width+pos[0]*sx
    py = parent.bottom-(amin[1]+(amax[1]-amin[1])*pivot[1])*parent.height-pos[1]*sy
    width, height = width*local[0], height*local[1]
    return Rect(px-pivot[0]*width, py-(1-pivot[1])*height, width, height)


@dataclass(frozen=True)
class LayoutItem:
    """One explicitly active participating child; dimensions are physical pixels.

    minimum/preferred/flexible apply on the layout's main axis. cross_size=None
    means stretch to the padded cross axis; hidden/ignored children are OMITTED
    by the caller. No activation/binding inference is performed here.
    """
    key: str
    minimum: float = 0
    preferred: float = 0
    flexible: float = 0
    cross_size: float | None = None

    def __post_init__(self):
        _nonempty(self.key, "layout child identity")
        for name in ("minimum", "preferred", "flexible"):
            object.__setattr__(self, name, _number(getattr(self, name), name, nonnegative=True))
        if self.preferred < self.minimum:
            raise LayoutError("preferred size is below minimum")
        if self.cross_size is not None:
            object.__setattr__(self, "cross_size", _number(self.cross_size, "cross size", positive=True))


def linear_layout(parent: Rect, active_children: Iterable[LayoutItem], *, axis: str,
                  spacing=0, padding=Insets(), alignment=0, cross_alignment=0) -> dict[str, Rect]:
    """Bounded Horizontal/VerticalLayoutGroup allocation, not a full MMML engine.

    Min->preferred interpolation when tight; beyond preferred, proportional
    positive flex allocation. Unallocated space uses explicit [0,1] alignment.
    Caller resolves content fitting, child-control flags, conditionals and units.
    No wrap, auto-height text, max constraints, rotation or implicit active items.
    """
    if axis not in ("horizontal", "vertical") or not isinstance(parent, Rect):
        raise LayoutError("resolved parent and layout axis required")
    try:
        children = tuple(active_children)
    except TypeError as exc:
        raise LayoutError("explicit active child list required") from exc
    if not children or not all(isinstance(c, LayoutItem) for c in children):
        raise LayoutError("explicit nonempty active child list required")
    if len({c.key for c in children}) != len(children):
        raise LayoutError("ambiguous duplicate active child identity")
    spacing = _number(spacing, "spacing", nonnegative=True)
    alignment, cross_alignment = (_number(v, "alignment") for v in (alignment, cross_alignment))
    if not all(0 <= v <= 1 for v in (alignment, cross_alignment)):
        raise LayoutError("alignment must be in [0,1]")
    inner = parent.inset(padding)
    horizontal = axis == "horizontal"
    main, cross = (inner.width, inner.height) if horizontal else (inner.height, inner.width)
    available = main-spacing*(len(children)-1)
    minimum, preferred, flex = (sum(getattr(c, attr) for c in children) for attr in ("minimum", "preferred", "flexible"))
    if available < minimum:
        raise LayoutError("active children overflow known parent")
    if available < preferred:
        factor = (available-minimum)/(preferred-minimum)
        sizes = [c.minimum+(c.preferred-c.minimum)*factor for c in children]
    else:
        sizes = [c.preferred+(available-preferred)*c.flexible/flex if flex else c.preferred for c in children]
    offset = max(0, available-sum(sizes))*alignment
    result = {}
    for child, size in zip(children, sizes):
        cross_size = cross if child.cross_size is None else child.cross_size
        if cross_size > cross:
            raise LayoutError("active child's cross size exceeds known parent")
        other = (cross-cross_size)*cross_alignment
        result[child.key] = (Rect(inner.left+offset, inner.top+other, size, cross_size) if horizontal
                             else Rect(inner.left+other, inner.top+offset, cross_size, size))
        offset += size+spacing
    return result


def scroll_content_rect(content: Rect, *, offset=(0, 0)) -> Rect:
    """Known physical scroll offset: positive means content moves left/up.

    The caller must provide current scroll state. This never chooses a scroll
    action, guesses an item index, or treats offscreen content as clickable.
    """
    if not isinstance(content, Rect):
        raise LayoutError("resolved scroll content rectangle required")
    x, y = _pair(offset, "scroll offset")
    if x < 0 or y < 0:
        raise LayoutError("scroll offset cannot be negative")
    return content.translated(-x, -y)


def fixed_grid_layout(parent: Rect, item_keys: Iterable[str], *, columns: int,
                      cell_size, spacing=(0, 0), padding=Insets(),
                      alignment=(0, 0), allow_content_overflow=False) -> dict[str, Rect]:
    """Explicit row-major grid mathematics, not guessed MMML grid semantics.

    The recovered caller supplies columns/cell sizes/active keys; this function
    never guesses a wrap threshold from a screenshot. Dimensions are physical
    pixels. Scrollable content may exceed its viewport only when explicitly
    requested; the caller must then carry the actual viewport clip to targets.
    """
    if not isinstance(parent, Rect) or type(columns) is not int or not 1 <= columns <= 4096:
        raise LayoutError("explicit parent and positive integer column count required")
    if isinstance(item_keys, (str, bytes, dict)):
        raise LayoutError("ordered unique item identities required")
    try:
        keys = tuple(item_keys)
    except TypeError as exc:
        raise LayoutError("ordered item identities required") from exc
    if len(keys) > 4096 or any(not isinstance(key, str) or not key.strip() for key in keys):
        raise LayoutError("invalid or oversized grid item list")
    if len(keys) != len(set(keys)):
        raise LayoutError("duplicate grid item identity")
    width, height = _pair(cell_size, "grid cell size", positive=True)
    dx, dy = _pair(spacing, "grid spacing")
    ax, ay = _pair(alignment, "grid alignment")
    if dx < 0 or dy < 0 or not 0 <= ax <= 1 or not 0 <= ay <= 1 or type(allow_content_overflow) is not bool:
        raise LayoutError("invalid grid spacing/alignment/overflow policy")
    inner = parent.inset(padding)
    if not keys:
        return {}
    rows = (len(keys) + columns - 1) // columns
    occupied_columns = min(columns, len(keys))
    total_width = occupied_columns*width+(occupied_columns-1)*dx
    total_height = rows*height+(rows-1)*dy
    _number(total_width, "grid width", positive=True)
    _number(total_height, "grid height", positive=True)
    if not allow_content_overflow and (total_width > inner.width or total_height > inner.height):
        raise LayoutError("grid exceeds supplied content rectangle")
    left = inner.left+max(0, inner.width-total_width)*ax
    top = inner.top+max(0, inner.height-total_height)*ay
    return {key: Rect(left+(index % columns)*(width+dx),
                      top+(index // columns)*(height+dy), width, height)
            for index, key in enumerate(keys)}


@dataclass(frozen=True)
class LayoutTarget:
    client_rect: Rect
    visible_rect: Rect
    click_rect: Rect
    ocr_rect: Rect
    client_point: tuple[int, int]
    screen_point: tuple[float, float]
    clipped: bool
    profile_provenance: str
    confirmed: bool


def target_from_rect(context: CanvasContext, rect: Rect, *, clip_rects=(), click_inset=3,
                     ocr_inset=0, require_confirmed=True) -> LayoutTarget:
    """Produce click and OCR geometry together; geometric output is NOT input approval.

    require_confirmed=False is analysis-only. It preserves confirmed=False in
    output and must not be used by a runtime actuator to bypass evidence guards.
    Occluders are not clipping rectangles and require the existing input guard.
    """
    if not isinstance(context, CanvasContext) or not isinstance(rect, Rect):
        raise LayoutError("resolved canvas context and target rectangle required")
    if type(require_confirmed) is not bool:
        raise LayoutError("confirmation requirement must be boolean")
    if require_confirmed and not context.confirmed:
        raise LayoutError("constructor/profile prediction is not confirmed runtime geometry")
    try:
        clips = tuple(clip_rects)
    except TypeError as exc:
        raise LayoutError("clipping context must be an explicit rectangle list") from exc
    visible = rect
    for clip in (context.root_rect, context.safe_rect, *clips):
        if not isinstance(clip, Rect):
            raise LayoutError("all clipping boundaries must be resolved rectangles")
        visible = visible.intersection(clip)
        if visible is None:
            raise LayoutError("target is offscreen or fully clipped")
    click = visible.inset(click_inset)
    ocr = visible.inset(ocr_inset)
    left, top, right, bottom = click.pixel_bounds(inward=True)
    x = min(right-1, max(left, round(click.center[0])))
    y = min(bottom-1, max(top, round(click.center[1])))
    ocr.pixel_bounds(inward=True)
    return LayoutTarget(rect, visible, click, ocr, (x, y), context.viewport.to_screen((x, y)),
                        visible != rect, context.profile.provenance, context.confirmed)


def resolve_target(context: CanvasContext, *, parent: Rect, transform: RectTransform,
                   unit_scale=None, clip_rects=(), click_inset=3, ocr_inset=0,
                   require_confirmed=True) -> LayoutTarget:
    """Resolve one authored child against its explicit current parent.

    unit_scale defaults to root canvas scale; nonuniform/scaled ancestors MUST
    pass their resolved cumulative scale. Dynamic layout groups are resolved by
    linear_layout first, then projected with target_from_rect, not this function.
    """
    if not isinstance(context, CanvasContext):
        raise LayoutError("resolved canvas context required")
    rect = child_rect(parent, transform, context.scale if unit_scale is None else unit_scale)
    return target_from_rect(context, rect, clip_rects=clip_rects, click_inset=click_inset,
                            ocr_inset=ocr_inset, require_confirmed=require_confirmed)
