"""Strict, inert, reloadable UI rule data packs; no OS/game/input access.

Checksums detect damaged or partially updated packs, not malicious authors.
Every reload is validated from scratch. Previously returned snapshots are
immutable but MUST NOT be used after a newer file fails validation. A pack does
not confirm a current canvas; the route observer supplies that evidence.
"""
from __future__ import annotations

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

from .paths import install_dir, resource_dir
from .ui_layout import CanvasProfile, LayoutError, RectTransform, Viewport, resolve_canvas
from .ui_rule_engine import ElementRule, SceneRule, StateCondition, calculate_scene
from .ui_rule_expression import dependency_names
from .ui_rule_target import ReviewedRuleBinding


MAX_PACK_BYTES = 1_048_576
MAX_ENTRIES = 64
MAX_ELEMENTS = 256
_SHA = re.compile(r"[0-9a-f]{64}\Z")
_ID = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,95}\Z")
_VARIABLE = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,63}\Z")
_ROLE = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+\Z")


class RulePackError(LayoutError):
    """Invalid/unavailable pack: do not silently use a stale pack or fallback."""


class UnsupportedRuleError(RulePackError):
    """Valid pack does not declare the requested semantic role/profile."""


def _keys(value, required, optional=()):
    if type(value) is not dict or set(value) != set(required) | (set(value) & set(optional)):
        raise RulePackError("missing or unknown rule-pack fields")
    return value


def _text(value, label, pattern=None, limit=2048):
    if type(value) is not str or not value.strip() or len(value) > limit:
        raise RulePackError("invalid " + label)
    if pattern is not None and not pattern.fullmatch(value):
        raise RulePackError("invalid " + label)
    return value


def _number(value, label, *, positive=False):
    try:
        valid = type(value) in (int, float) and math.isfinite(value) and abs(value) <= 1e12
    except OverflowError:
        valid = False
    if not valid or (positive and value <= 0):
        raise RulePackError("invalid " + label)
    return value


def _array(value, label, *, maximum=256, empty=False):
    if type(value) is not list or len(value) > maximum or (not empty and not value):
        raise RulePackError("invalid " + label)
    return value


def _freeze(value):
    if type(value) is dict:
        return MappingProxyType({key: _freeze(item) for key, item in value.items()})
    if type(value) is list:
        return tuple(_freeze(item) for item in value)
    return value


def _inert(value, depth=0):
    if depth > 24:
        raise RulePackError("rule-pack nesting limit exceeded")
    if value is None or type(value) is bool:
        return
    if type(value) in (int, float):
        _number(value, "JSON number")
    elif type(value) is str:
        if len(value) > 8192:
            raise RulePackError("rule-pack string limit exceeded")
    elif type(value) is list:
        if len(value) > 4096:
            raise RulePackError("rule-pack array limit exceeded")
        for item in value:
            _inert(item, depth + 1)
    elif type(value) is dict:
        if len(value) > 256 or any(type(key) is not str or len(key) > 128 for key in value):
            raise RulePackError("rule-pack object limit exceeded")
        for item in value.values():
            _inert(item, depth + 1)
    else:
        raise RulePackError("only inert JSON values are permitted")


def _unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise RulePackError("duplicate JSON key: " + key)
        result[key] = value
    return result


def _invalid_constant(value):
    raise RulePackError("non-finite JSON constant: " + value)


def _decode(raw):
    if type(raw) is not bytes or not 0 < len(raw) <= MAX_PACK_BYTES:
        raise RulePackError("rule pack must be bounded nonempty UTF-8 bytes")
    try:
        value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object,
                           parse_constant=_invalid_constant)
    except (UnicodeError, ValueError, RecursionError) as exc:
        raise RulePackError("invalid rule-pack JSON: " + str(exc)) from exc
    _inert(value)
    return value


def pack_content_hash(document):
    """Canonical semantic checksum, excluding only its own top-level field."""
    _inert(document)
    if type(document) is not dict:
        raise RulePackError("pack document must be an object")
    content = {key: value for key, value in document.items() if key != "content_sha256"}
    return hashlib.sha256(json.dumps(content, ensure_ascii=True, sort_keys=True,
        separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class ParameterSpec:
    kind: str
    minimum: int | float
    maximum: int | float
    description: str

    def validate(self, value):
        _number(value, "parameter")
        if self.kind == "integer" and type(value) is not int:
            raise RulePackError("integer parameter required")
        if not self.minimum <= value <= self.maximum:
            raise RulePackError("parameter outside reviewed source range")
        return value


@dataclass(frozen=True)
class RuleEntry:
    role: str
    target_key: str
    scene: SceneRule
    binding: ReviewedRuleBinding
    profile: CanvasProfile
    required_state: tuple[StateCondition, ...]
    parameter_specs: Mapping[str, ParameterSpec]
    reviewed_scope: Mapping

    def validate_profile(self, style, percent, orientation):
        if type(percent) is not int or (style, percent, orientation) not in self.binding.allowed_profiles:
            raise UnsupportedRuleError("profile is not declared for role " + self.role)

    def validate_inputs(self, state, parameters):
        if not isinstance(state, Mapping) or not isinstance(parameters, Mapping):
            raise RulePackError("explicit current state and parameters required")
        for condition in self.required_state:
            if condition.matches(state) is not True:
                raise RulePackError("current route state not established: " + condition.key)
        if set(parameters) != set(self.parameter_specs):
            raise RulePackError("missing or unknown scene parameters")
        return {name: spec.validate(parameters[name]) for name, spec in self.parameter_specs.items()}

    def calculate(self, viewport: Viewport, *, style, percent, orientation, state, parameters):
        """Unconfirmed offline projection. Runtime confirmation is separate."""
        self.validate_profile(style, percent, orientation)
        values = self.validate_inputs(state, parameters)
        context = resolve_canvas(viewport, style, percent, orientation=orientation, profile=self.profile)
        # Auto resolves to an effective percentage from the current viewport.
        self.validate_profile(context.style, context.percent, context.orientation)
        return calculate_scene(self.scene, context, state=state, parameters=values)


@dataclass(frozen=True)
class RulePack:
    pack_id: str
    revision: str
    content_sha256: str
    file_sha256: str
    source_path: str
    source_fingerprints: tuple[Mapping, ...]
    entries: Mapping[str, RuleEntry]
    profiles: Mapping[str, CanvasProfile]
    screen_padding: Mapping | None = None

    def scene_for(self, role):
        if type(role) is not str or role not in self.entries:
            raise UnsupportedRuleError("semantic UI role is not in this pack")
        return self.entries[role]

    def profile_for(self, orientation):
        if type(orientation) is not str or orientation not in self.profiles:
            raise UnsupportedRuleError("canvas orientation is not in this pack")
        return self.profiles[orientation]

    @property
    def profile(self):
        return self.profile_for("landscape")


def _conditions(raw):
    conditions = []
    for item in _array(raw, "required state", maximum=32, empty=True):
        _keys(item, ("key", "equals"))
        _text(item["key"], "state name", _VARIABLE)
        condition = StateCondition(item["key"], item["equals"])
        if any(old.key == condition.key for old in conditions):
            raise RulePackError("duplicate state condition")
        conditions.append(condition)
    return tuple(conditions)


def _scene(raw, source_hash, required, parameters):
    _keys(raw, ("scene_id", "revision", "source_ref", "provenance", "elements"))
    elements = []
    for item in _array(raw["elements"], "elements", maximum=MAX_ELEMENTS):
        _keys(item, ("key", "parent"), ("formulae", "transform", "when", "enabled_state",
             "unit_multiplier", "clips", "clip_children", "scroll_state", "role"))
        _text(item["key"], "element key", _VARIABLE)
        _text(item["parent"], "element parent", _VARIABLE)
        if ("formulae" in item) == ("transform" in item):
            raise RulePackError("one explicit geometry definition required")
        formulae, transform = None, None
        if "formulae" in item:
            values = _array(item["formulae"], "formulae", maximum=4)
            if len(values) != 4:
                raise RulePackError("four geometry formulae required")
            formulae = tuple(_text(value, "formula") for value in values)
        else:
            t = _keys(item["transform"], ("anchor_min", "anchor_max", "pivot", "anchored_position", "size_delta", "local_scale"))
            for key, values in t.items():
                if type(values) is not list or len(values) != 2:
                    raise RulePackError("transform requires numeric pairs")
                for value in values:
                    _number(value, "transform " + key)
            transform = RectTransform(**{key: tuple(value) for key, value in t.items()})
        when = _conditions(item.get("when", []))
        # Required route state is structural too, not solely an optional helper.
        combined = {condition.key: condition for condition in required}
        for condition in when:
            if condition.key in combined and combined[condition.key] != condition:
                raise RulePackError("element contradicts required route state")
            combined[condition.key] = condition
        clips = tuple(_text(v, "clip reference", _VARIABLE) for v in _array(item.get("clips", []), "clips", maximum=32, empty=True))
        for field in ("enabled_state", "scroll_state"):
            if item.get(field) is not None:
                _text(item[field], field, _VARIABLE)
        if item.get("role") is not None:
            _text(item["role"], "semantic role", _ROLE, 128)
        elements.append(ElementRule(item["key"], item["parent"], formulae=formulae, transform=transform,
            when=tuple(combined.values()), enabled_state=item.get("enabled_state"),
            unit_multiplier=_number(item.get("unit_multiplier", 1), "unit multiplier", positive=True),
            clips=clips, clip_children=item.get("clip_children", False), scroll_state=item.get("scroll_state"), role=item.get("role")))
    scene = SceneRule(_text(raw["scene_id"], "scene id", _ID), _text(raw["revision"], "scene revision", _ID),
        source_hash, tuple(elements), _text(raw["provenance"], "scene provenance"))
    keys = {node.key for node in elements}
    dependencies, all_parameters = {}, set()
    for node in elements:
        refs = {node.parent, *node.clips}
        for expression in node.formulae or ():
            parsed = dependency_names(expression)
            refs.update(node.parent if value == "parent" else value for value in parsed.rectangles)
            all_parameters.update(parsed.parameters)
        if refs - keys - {"screen", "safeArea"}:
            raise RulePackError("undeclared geometry reference")
        dependencies[node.key] = refs & keys
    if all_parameters != set(parameters) or set(parameters) & (keys | {"screen", "safeArea", "parent"}):
        raise RulePackError("parameter declarations do not match the scene")
    pending = dict(dependencies)
    while pending:
        ready = {key for key, refs in pending.items() if not refs}
        if not ready:
            raise RulePackError("cyclic geometry dependency")
        pending = {key: refs - ready for key, refs in pending.items() if key not in ready}
    return scene


def _parse(raw, source_path, *, verify_hashes=True):
    document = _decode(raw)
    _keys(document, ("schema_version", "pack_id", "revision", "content_sha256", "source_fingerprints", "canvas_profiles", "entries"), ("screen_padding",))
    if type(document["schema_version"]) is not int or document["schema_version"] != 1:
        raise RulePackError("unsupported rule-pack schema version")
    pack_id = _text(document["pack_id"], "pack id", _ID)
    revision = _text(document["revision"], "pack revision", _ID)
    _text(document["content_sha256"], "pack checksum", _SHA)
    checksum = pack_content_hash(document)
    if verify_hashes and checksum != document["content_sha256"]:
        raise RulePackError("rule-pack content checksum mismatch")
    sources = {}
    for row in _array(document["source_fingerprints"], "sources", maximum=64):
        _keys(row, ("id", "kind", "logical_path", "sha256"))
        key = _text(row["id"], "source id", _ID)
        if key in sources:
            raise RulePackError("duplicate source id")
        for field in ("kind", "logical_path"):
            _text(row[field], "source " + field)
        sources[key] = _text(row["sha256"], "source fingerprint", _SHA)
    screen_padding = document.get("screen_padding")
    if screen_padding is not None:
        from .source_screen_padding import validate_screen_padding
        validate_screen_padding(screen_padding)
        refs = _array(screen_padding["source_refs"], "screen padding sources", maximum=16)
        if any(type(ref) is not str or ref not in sources for ref in refs) or len(refs) != len(set(refs)):
            raise RulePackError("invalid screen padding source reference")
    profiles, profiles_by_id = {}, {}
    for row in _array(document["canvas_profiles"], "canvas profiles", maximum=8):
        _keys(row, ("id", "orientation", "reference_width", "reference_height", "minimum_width", "provenance", "source_refs"))
        key = _text(row["id"], "profile id", _ID)
        orientation = row["orientation"]
        if orientation not in ("portrait", "landscape") or orientation in profiles or key in profiles_by_id:
            raise RulePackError("invalid/duplicate canvas profile")
        refs = _array(row["source_refs"], "profile sources", maximum=16)
        if any(type(ref) is not str or ref not in sources for ref in refs) or len(refs) != len(set(refs)):
            raise RulePackError("invalid profile source reference")
        profile = CanvasProfile(*(_number(row[key], key, positive=True) for key in ("reference_width", "reference_height", "minimum_width")),
            _text(row["provenance"], "profile provenance"), False)
        profiles[orientation] = profile
        profiles_by_id[key] = (orientation, profile)
    entries, hashes = {}, {}
    for row in _array(document["entries"], "route entries", maximum=MAX_ENTRIES):
        _keys(row, ("role", "target_key", "canvas_profile", "required_state", "parameters", "scene", "binding", "reviewed_scope"))
        role = _text(row["role"], "semantic role", _ROLE, 128)
        target_key = _text(row["target_key"], "target key", _VARIABLE)
        if role in entries:
            raise RulePackError("duplicate semantic role")
        if row["canvas_profile"] not in profiles_by_id:
            raise RulePackError("undeclared canvas profile")
        orientation, profile = profiles_by_id[row["canvas_profile"]]
        required = _conditions(row["required_state"])
        if type(row["parameters"]) is not dict or len(row["parameters"]) > 32:
            raise RulePackError("bounded parameter declarations required")
        specs = {}
        for name, value in row["parameters"].items():
            _text(name, "parameter name", _VARIABLE)
            _keys(value, ("type", "minimum", "maximum", "description"))
            if value["type"] not in ("integer", "number"):
                raise RulePackError("unsupported parameter type")
            lo, hi = _number(value["minimum"], "parameter minimum"), _number(value["maximum"], "parameter maximum")
            if lo > hi or (value["type"] == "integer" and (type(lo) is not int or type(hi) is not int)):
                raise RulePackError("invalid parameter interval")
            specs[name] = ParameterSpec(value["type"], lo, hi, _text(value["description"], "parameter description"))
        if type(row["scene"]) is not dict or row["scene"].get("source_ref") not in sources:
            raise RulePackError("undeclared scene source")
        scene = _scene(row["scene"], sources[row["scene"]["source_ref"]], required, specs)
        targets = [node for node in scene.elements if node.role is not None]
        if len(targets) != 1 or targets[0].key != target_key or targets[0].role != role:
            raise RulePackError("exact target key and semantic role must agree")
        b = _keys(row["binding"], ("program_sha256", "allowed_roles", "allowed_profiles", "evidence"))
        _text(b["program_sha256"], "program checksum", _SHA)
        roles = tuple(_array(b["allowed_roles"], "allowed roles", maximum=1))
        if roles != (role,):
            raise RulePackError("binding role whitelist mismatch")
        allowed = []
        for item in _array(b["allowed_profiles"], "allowed profiles", maximum=16):
            if type(item) is not list or len(item) != 3 or item[2] != orientation:
                raise RulePackError("binding/profile orientation mismatch")
            allowed.append(tuple(item))
        signature = scene.definition_signature
        if verify_hashes and signature != b["program_sha256"]:
            raise RulePackError("reviewed scene program checksum mismatch")
        binding = ReviewedRuleBinding(scene.scene_id, scene.revision, scene.source_sha256, signature,
            roles, tuple(allowed), _text(b["evidence"], "binding review evidence"))
        scope = _keys(row["reviewed_scope"], ("family", "source_model", "observed_queue_counts", "independent_conditions", "limitations"))
        _text(scope["family"], "reviewed family")
        _text(scope["source_model"], "reviewed source model")
        for item in _array(scope["observed_queue_counts"], "observed queue counts", maximum=32, empty=True):
            if type(item) is not int or not 1 <= item <= 64:
                raise RulePackError("invalid observed queue count")
        for key in ("independent_conditions", "limitations"):
            for text in _array(scope[key], key, maximum=32):
                _text(text, key)
        entry = RuleEntry(role, target_key, scene, binding, profile, required, MappingProxyType(specs), _freeze(scope))
        # Validate supported expression dimensions and parameter endpoints with
        # pure synthetic canvases. This does not establish current UI identity.
        probe_state = {condition.key: condition.equals for condition in required}
        for node in scene.elements:
            if node.enabled_state is not None:
                probe_state.setdefault(node.enabled_state, True)
        for endpoint in ("minimum", "maximum"):
            params = {name: getattr(spec, endpoint) for name, spec in specs.items()}
            style, percent, direction = allowed[0]
            context = resolve_canvas(Viewport(3840, 2160), style, percent, orientation=direction, profile=profile)
            calculated = calculate_scene(scene, context, state=probe_state, parameters=params)
            if calculated.elements[target_key].status != "resolved":
                raise RulePackError("scene fails endpoint geometry validation")
        entries[role], hashes[role] = entry, signature
    snapshot = RulePack(pack_id, revision, checksum, hashlib.sha256(raw).hexdigest(), str(source_path),
        tuple(_freeze(row) for row in document["source_fingerprints"]), MappingProxyType(entries), MappingProxyType(profiles),
        _freeze(screen_padding))
    return snapshot, {"content_sha256": checksum, "program_sha256_by_role": hashes}


def load_rule_pack_bytes(raw: bytes, *, source_path="<bytes>", expected_sha256=None) -> RulePack:
    if expected_sha256 is not None:
        _text(expected_sha256, "expected file SHA256", _SHA)
        if type(raw) is not bytes or hashlib.sha256(raw).hexdigest() != expected_sha256:
            raise RulePackError("rule-pack file fingerprint mismatch")
    try:
        return _parse(raw, source_path)[0]
    except RulePackError:
        raise
    except (LayoutError, TypeError, ValueError, KeyError, OverflowError, RecursionError) as exc:
        raise RulePackError("invalid rule pack: " + str(exc)) from exc


def default_rule_pack_path() -> Path:
    active = install_dir() / "ui-rules/active.json"
    # Even an unreadable file/directory/symlink at this path is an explicit
    # override; an invalid active pack must never fall back to bundled rules.
    try:
        active.lstat()
    except FileNotFoundError:
        return resource_dir() / "ui-rules/default.json"
    except OSError as exc:
        raise RulePackError("cannot inspect active rule pack: " + str(active)) from exc
    return active


def load_rule_pack(path=None, *, expected_sha256=None) -> RulePack:
    """Read/validate a new immutable snapshot. Never cache a last-good fallback."""
    try:
        path = default_rule_pack_path() if path is None else Path(path)
        with path.open("rb") as stream:
            raw = stream.read(MAX_PACK_BYTES + 1)
    except RulePackError:
        raise
    except (TypeError, ValueError) as exc:
        raise RulePackError("invalid rule-pack path") from exc
    except OSError as exc:
        raise RulePackError("cannot read rule pack: " + str(path)) from exc
    return load_rule_pack_bytes(raw, source_path=str(path), expected_sha256=expected_sha256)


def inspection_hashes(raw: bytes):
    """Authoring aid only: validates structure, reports hashes, returns no pack.

    A changed program hash must first be inserted; then recalculate the content
    hash because the binding hash itself is part of the pack's semantic content.
    No files are modified and this API must not be used by the input adapter.
    """
    try:
        return _parse(raw, "<authoring inspection>", verify_hashes=False)[1]
    except RulePackError:
        raise
    except (LayoutError, TypeError, ValueError, KeyError, OverflowError, RecursionError) as exc:
        raise RulePackError("invalid rule-pack authoring input: " + str(exc)) from exc
