"""State-dependent, authored UI rule graph in physical client coordinates.

This evaluates recovered/specified formulae; it is NOT a replacement Namoo VM
and does not infer which template the game has instantiated. Unresolved inputs
are returned to the observer, not replaced with old PC70 coordinates. Source
binding and input authority remain separate from mathematical resolution.
"""
from __future__ import annotations

from dataclasses import dataclass, field, replace
from collections import deque
import hashlib
import json
import math
import re
from types import MappingProxyType
from typing import Mapping

from .ui_layout import CanvasContext, LayoutError, LayoutTarget, Rect, RectTransform, child_rect, target_from_rect
from .ui_rule_expression import dependency_names, evaluate_expression


_IDENTIFIER = re.compile(r"[A-Za-z][A-Za-z0-9_]*\Z")
_RESERVED = {"screen", "safeArea", "parent"}


def _identifier(value):
    if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value):
        raise LayoutError("explicit simple UI identifier required")
    return value


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


def _json_value(value):
    """Only inert state values; no arbitrary object/string conversion."""
    if value is None or type(value) in (bool, str, int):
        return value
    if type(value) is float:
        return _finite(value, "state value")
    if type(value) in (list, tuple):
        return [_json_value(item) for item in value]
    if isinstance(value, Mapping):
        if any(not isinstance(key, str) for key in value):
            raise LayoutError("state keys must be strings")
        return {key: _json_value(item) for key, item in value.items()}
    raise LayoutError("state must contain plain JSON values")


def _digest(value):
    return hashlib.sha256(json.dumps(value, ensure_ascii=True, sort_keys=True,
                                    separators=(",", ":"), allow_nan=False).encode()).hexdigest()


@dataclass(frozen=True)
class StateCondition:
    key: str
    equals: str | int | float | bool | None

    def __post_init__(self):
        _identifier(self.key)
        if self.equals is not None and type(self.equals) not in (str, int, float, bool):
            raise LayoutError("condition requires one literal value")
        _json_value(self.equals)

    def matches(self, state):
        if self.key not in state:
            return None
        value = state[self.key]
        # False/0, True/1 must not silently choose the same UI branch.
        return type(value) is type(self.equals) and value == self.equals


@dataclass(frozen=True)
class ElementRule:
    key: str
    parent: str
    # Formulae specify physical left/top/width/height using named rectangles
    # and explicit UI-unit px literals. No implicit top-left/anchor default.
    formulae: tuple[str, str, str, str] | None = None
    transform: RectTransform | None = None
    when: tuple[StateCondition, ...] = ()
    enabled_state: str | None = None
    unit_multiplier: float = 1.0
    clips: tuple[str, ...] = ()
    clip_children: bool = False
    scroll_state: str | None = None
    role: str | None = None

    def __post_init__(self):
        _identifier(self.key)
        _identifier(self.parent)
        if self.key in _RESERVED or (self.formulae is None) == (self.transform is None):
            raise LayoutError("one explicit geometry definition is required per element")
        if self.formulae is not None:
            if type(self.formulae) is not tuple or len(self.formulae) != 4:
                raise LayoutError("formulae must be an immutable left/top/width/height tuple")
            for expr in self.formulae:
                dependency_names(expr)
        if self.transform is not None:
            if not isinstance(self.transform, RectTransform):
                raise LayoutError("invalid rect transform")
            if self.transform.local_scale[0] != self.transform.local_scale[1]:
                raise LayoutError("graph descendants need an explicit uniform scale; nonuniform transforms remain standalone")
        if type(self.when) is not tuple or not all(isinstance(c, StateCondition) for c in self.when):
            raise LayoutError("conditions must be an immutable condition tuple")
        if type(self.clips) is not tuple:
            raise LayoutError("clipping references must be an immutable tuple")
        for name in self.clips:
            _identifier(name)
        for name in (self.enabled_state, self.scroll_state):
            if name is not None:
                _identifier(name)
        _finite(self.unit_multiplier, "unit multiplier", positive=True)
        if type(self.clip_children) is not bool:
            raise LayoutError("clip_children must be explicit bool")
        if self.role is not None and (not isinstance(self.role, str) or not self.role.strip()):
            raise LayoutError("invalid button role")


@dataclass(frozen=True)
class SceneRule:
    scene_id: str
    revision: str
    source_sha256: str
    elements: tuple[ElementRule, ...]
    provenance: str

    def __post_init__(self):
        for text in (self.scene_id, self.revision, self.provenance):
            if not isinstance(text, str) or not text.strip():
                raise LayoutError("scene identity, revision and provenance required")
        if not isinstance(self.source_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", self.source_sha256):
            raise LayoutError("scene source SHA256 required")
        if type(self.elements) is not tuple or not 0 < len(self.elements) <= 4096:
            raise LayoutError("bounded immutable element list required")
        if not all(isinstance(item, ElementRule) for item in self.elements):
            raise LayoutError("invalid scene element")
        keys = [item.key for item in self.elements]
        roles = [item.role for item in self.elements if item.role is not None]
        if len(keys) != len(set(keys)) or len(roles) != len(set(roles)):
            raise LayoutError("ambiguous element identity or button role")

    @property
    def definition_signature(self):
        """Pin the entire authored program, not just its source/revision label."""
        return _digest(_scene_description(self))


@dataclass(frozen=True)
class ElementGeometry:
    key: str
    status: str
    rect: Rect | None = None
    unit_scale: float | None = None
    clips: tuple[Rect, ...] = ()
    missing: tuple[str, ...] = ()
    enabled: bool = False
    role: str | None = None


@dataclass(frozen=True)
class SceneCalculation:
    scene: SceneRule
    context: CanvasContext
    elements: Mapping[str, ElementGeometry]
    input_signature: str
    # Mathematical resolution never turns into current-instance authorization.
    runtime_click_approved: bool = field(default=False, init=False)

    def target(self, key: str, *, click_inset=3, ocr_inset=0) -> LayoutTarget:
        item = self.elements.get(key)
        if item is None or item.status != "resolved" or not item.enabled:
            status = item.status if item else "not_declared"
            raise LayoutError(f"UI target not ready: {key} ({status})")
        result = target_from_rect(self.context, item.rect, clip_rects=item.clips,
                                  click_inset=click_inset, ocr_inset=ocr_inset,
                                  require_confirmed=False)
        return replace(result, confirmed=False)

    def by_role(self, role: str, **kwargs) -> LayoutTarget:
        keys = [key for key, value in self.elements.items() if value.role == role]
        if len(keys) != 1:
            raise LayoutError("button role is undeclared or ambiguous")
        return self.target(keys[0], **kwargs)


def _scene_description(scene):
    transforms = []
    for node in scene.elements:
        t = node.transform
        transforms.append({"key": node.key, "parent": node.parent, "formulae": node.formulae,
                           "transform": None if t is None else {"min": t.anchor_min, "max": t.anchor_max,
                           "pivot": t.pivot, "position": t.anchored_position, "delta": t.size_delta,
                           "scale": t.local_scale}, "when": [(c.key, c.equals) for c in node.when],
                           "enabled_state": node.enabled_state, "unit_multiplier": node.unit_multiplier,
                           "clips": node.clips, "clip_children": node.clip_children,
                           "scroll_state": node.scroll_state, "role": node.role})
    return {"scene": [scene.scene_id, scene.revision, scene.source_sha256],
            "program": transforms, "provenance": scene.provenance}


def calculate_scene(scene: SceneRule, context: CanvasContext, *, state: Mapping,
                    parameters: Mapping[str, float] | None = None,
                    external_rects: Mapping[str, Rect] | None = None) -> SceneCalculation:
    """Resolve dependencies, conditions, hierarchy and scroll without OS input.

    Scroll state is (x,y) in physical pixels for a known content container;
    positive moves the content left/up. Supply it only to that container, not
    each descendant. No content-fitting/flow/grid semantics are guessed here;
    the author supplies recovered formulae or resolved external layout boxes.
    """
    if not isinstance(scene, SceneRule) or not isinstance(context, CanvasContext) or not isinstance(state, Mapping):
        raise LayoutError("explicit scene/context/state required")
    values = _json_value(state)
    parameters = dict(parameters or {})
    for name, value in parameters.items():
        _identifier(name)
        _finite(value, f"parameter {name}")
    boxes = {"screen": context.root_rect, "safeArea": context.safe_rect}
    external_rects = dict(external_rects or {})
    declared = {node.key: node for node in scene.elements}
    if any(name in _RESERVED or name in declared or name in parameters for name in external_rects):
        raise LayoutError("external rectangle shadows a reserved/declared name")
    for name, rect in external_rects.items():
        _identifier(name)
        if not isinstance(rect, Rect):
            raise LayoutError("external parents must be resolved physical rectangles")
        boxes[name] = rect
    if set(parameters) & (set(boxes) | set(declared) | {"parent"}):
        raise LayoutError("scalar parameter shadows a rectangle identifier")
    # Parse each expression once, then visit dependencies before consumers.
    # A reverse-ordered 4096-element chain must not cause millions of AST parses.
    dependencies = {}
    consumers = {key: [] for key in declared}
    degrees = {}
    for key, node in declared.items():
        refs, param_refs = {node.parent, *node.clips}, set()
        for expr in node.formulae or ():
            deps = dependency_names(expr)
            refs.update(node.parent if name == "parent" else name for name in deps.rectangles)
            param_refs.update(deps.parameters)
        dependencies[key] = (refs, param_refs)
        internal = refs & declared.keys()
        degrees[key] = len(internal)
        for ref in internal:
            consumers[ref].append(key)
    ready = deque(key for key, degree in degrees.items() if degree == 0)
    ordered = []
    while ready:
        key = ready.popleft()
        ordered.append(key)
        for consumer in consumers[key]:
            degrees[consumer] -= 1
            if degrees[consumer] == 0:
                ready.append(consumer)
    ordered.extend(key for key in declared if degrees[key] > 0)
    result = {}
    pending = {key: declared[key] for key in ordered}

    def record(node, status, *, missing=(), rect=None, scale=None, clips=(), enabled=False):
        result[node.key] = ElementGeometry(node.key, status, rect, scale, clips,
                                          tuple(sorted(set(missing))), enabled, node.role)
        pending.pop(node.key)

    while pending:
        progress = False
        for key, node in tuple(pending.items()):
            conditions = [c.matches(values) for c in node.when]
            if False in conditions:
                record(node, "hidden")
                progress = True
                continue
            if None in conditions:
                record(node, "missing_state", missing=[c.key for c in node.when if c.key not in values])
                progress = True
                continue
            refs, param_refs = dependencies[key]
            absent = (refs - set(boxes) - set(declared)) | (param_refs - set(parameters))
            if absent:
                record(node, "missing_reference", missing=absent)
                progress = True
                continue
            blocked = [name for name in refs if name in result and result[name].status not in ("resolved", "disabled", "missing_enabled_state")]
            if blocked:
                record(node, "unresolved_dependency", missing=blocked)
                progress = True
                continue
            if not refs <= boxes.keys():
                continue
            parent = result.get(node.parent)
            base_scale = context.scale if parent is None else parent.unit_scale
            scale = _finite(base_scale * node.unit_multiplier, "cumulative scale", positive=True)
            scope = dict(boxes, parent=boxes[node.parent])
            try:
                if node.formulae:
                    parts = [evaluate_expression(expr, scope, unit_scale=scale, parameters=parameters) for expr in node.formulae]
                    if any(part.kind != "length" for part in parts):
                        raise LayoutError("rectangle expressions must return lengths (use 0px, not bare 0)")
                    rect = Rect(*(part.value for part in parts))
                else:
                    rect = child_rect(boxes[node.parent], node.transform, scale)
                    scale = _finite(scale * node.transform.local_scale[0], "descendant scale", positive=True)
                if node.scroll_state:
                    if node.scroll_state not in values:
                        record(node, "missing_state", missing=(node.scroll_state,))
                        progress = True
                        continue
                    offset = values[node.scroll_state]
                    if not isinstance(offset, list) or len(offset) != 2:
                        raise LayoutError("scroll state needs physical (x,y)")
                    dx, dy = (_finite(v, "scroll offset") for v in offset)
                    if dx < 0 or dy < 0:
                        raise LayoutError("negative scroll offset")
                    rect = rect.translated(-dx, -dy)
                clips = list(parent.clips if parent else ())
                if parent and declared[node.parent].clip_children:
                    clips.append(parent.rect)
                clips.extend(boxes[name] for name in node.clips)
                boxes[key] = rect
                missing_enabled = list(parent.missing if parent and parent.status == "missing_enabled_state" else ())
                if node.enabled_state and node.enabled_state not in values:
                    missing_enabled.append(node.enabled_state)
                if missing_enabled:
                    record(node, "missing_enabled_state", missing=missing_enabled, rect=rect, scale=scale, clips=tuple(clips))
                else:
                    enabled = True if node.enabled_state is None else values[node.enabled_state]
                    if type(enabled) is not bool:
                        raise LayoutError("interactable state must be bool")
                    # An ancestor's disabled state also disables its descendants.
                    enabled = enabled and (parent.enabled if parent else True)
                    record(node, "resolved" if enabled else "disabled", rect=rect, scale=scale,
                           clips=tuple(clips), enabled=enabled)
            except LayoutError:
                # Malformed authored rules are errors, not a cue to substitute
                # a guessed position or to advance the game's operation.
                raise
            progress = True
        if not progress:
            cycle_keys = tuple(pending)
            for node in tuple(pending.values()):
                record(node, "cyclic_dependency", missing=cycle_keys)
            break
    signature = _digest({"definition_signature": scene.definition_signature,
                         "state": values, "parameters": parameters,
                         "external": {k: v.bounds for k, v in external_rects.items()},
                         "viewport": [context.viewport.width, context.viewport.height, context.safe_rect.bounds],
                         "canvas": [context.orientation, context.style, context.percent, context.scale,
                                    context.style_scale_applied, context.profile.provenance,
                                    context.profile.reference_width, context.profile.reference_height,
                                    context.profile.minimum_width, context.profile.confirmed]})
    return SceneCalculation(scene, context, MappingProxyType(result), signature)
