"""Data-only execution rules, staged once and atomically adopted at boundaries.

No game input, evaluation of Python, worker or global input hook belongs here.
One immutable snapshot owns policy/route/coordinate overrides for every reader.
"""
from __future__ import annotations

from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from functools import wraps
import hashlib
import json
import math
import os
from pathlib import Path
import re
import threading
import time
from types import MappingProxyType

from .paths import install_dir, resource_dir, DATA_FOLDER_NAME, plain_data_file

MAX_BYTES = 4 * 1024 * 1024
_KEY = re.compile(r"[A-Za-z0-9_.:-]{1,160}\Z")
_reading_snapshot = ContextVar("execution_rule_snapshot", default=None)


def _file_mark(stat):
    return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)


def _optional_stat(path):
    try:
        return path.stat()
    except FileNotFoundError:
        return None


class ExecutionRuleError(ValueError):
    pass


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


def _copy(value):
    if hasattr(value, "items"):
        return {key: _copy(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_copy(item) for item in value]
    return value


def _inert(value, depth=0):
    if depth > 20:
        raise ExecutionRuleError("규칙 중첩이 너무 깊습니다.")
    if value is None or type(value) is bool:
        return
    if type(value) in (int, float):
        if not math.isfinite(value) or abs(value) > 1e12:
            raise ExecutionRuleError("규칙 숫자가 유효하지 않습니다.")
    elif type(value) is str:
        if len(value) > 8192:
            raise ExecutionRuleError("규칙 문자열이 너무 깁니다.")
    elif type(value) is list:
        if len(value) > 8192:
            raise ExecutionRuleError("규칙 목록이 너무 큽니다.")
        for item in value:
            _inert(item, depth+1)
    elif type(value) is dict:
        if len(value) > 8192 or any(type(key) is not str or not key or len(key) > 160 for key in value):
            raise ExecutionRuleError("규칙 키가 유효하지 않습니다.")
        for item in value.values():
            _inert(item, depth+1)
    else:
        raise ExecutionRuleError("규칙은 JSON 자료만 허용합니다.")


def _unique(pairs):
    result = {}
    for key, item in pairs:
        if key in result:
            raise ExecutionRuleError("중복 규칙 키: " + key)
        result[key] = item
    return result


def decode(raw):
    if not isinstance(raw, (bytes, str)) or not 0 < len(raw) <= MAX_BYTES:
        raise ExecutionRuleError("규칙 파일 크기를 확인해 주세요.")
    try:
        document = json.loads(raw, object_pairs_hook=_unique,
            parse_constant=lambda value: (_ for _ in ()).throw(ExecutionRuleError("비유한 숫자")))
    except (ValueError, UnicodeError, RecursionError) as exc:
        raise ExecutionRuleError("실행 규칙 JSON을 읽지 못했습니다.") from exc
    return validate(document)


def validate(document):
    _inert(document)
    if type(document) is not dict or set(document)-{"schema", "revision", "values", "routes", "coordinates", "fixed_targets"}:
        raise ExecutionRuleError("알 수 없는 실행 규칙 항목입니다.")
    if type(document.get("schema")) is not int or document["schema"] != 1 or type(document.get("revision")) is not str or not document["revision"].strip():
        raise ExecutionRuleError("실행 규칙 schema/revision이 필요합니다.")
    for group in ("values", "routes"):
        if type(document.get(group, {})) is not dict:
            raise ExecutionRuleError(group + "는 키별 객체여야 합니다.")
        if any(not _KEY.fullmatch(key) for key in document.get(group, {})):
            raise ExecutionRuleError(group + " 규칙 키 형식이 잘못됐습니다.")
    for group in ("coordinates", "fixed_targets"):
        if type(document.get(group, [])) is not list:
            raise ExecutionRuleError(group + "는 목록이어야 합니다.")
    for steps in document.get("routes", {}).values():
        if type(steps) is not list or len(steps) > 256:
            raise ExecutionRuleError("경로는 최대256개 JSON 단계만 허용합니다.")
    for key in ("focus.idle_seconds", "focus.retry_seconds"):
        if key in document.get("values", {}) and (type(document["values"][key]) not in (int, float) or document["values"][key] <= 0):
            raise ExecutionRuleError("전경 복귀 대기는 양수여야 합니다: " + key)
    observations = document.get("values", {}).get("ocr.region_overrides", [])
    if type(observations) is not list:
        raise ExecutionRuleError("ocr.region_overrides는 영역 목록이어야 합니다.")
    for row in observations:
        if type(row) is not dict or set(row)-{"from", "to", "size"}:
            raise ExecutionRuleError("판독 영역 교정 항목을 확인해 주세요.")
        for key in ("from", "to"):
            box = row.get(key)
            if (type(box) is not list or len(box) != 4 or
                    any(type(v) not in (int, float) or not 0 <= v <= 1 for v in box) or
                    box[0] >= box[2] or box[1] >= box[3]):
                raise ExecutionRuleError("판독 영역은 정규화된 x1/y1/x2/y2이어야 합니다.")
        if "size" in row and (type(row["size"]) is not list or len(row["size"]) != 2 or
                any(type(v) is not int or v <= 0 for v in row["size"])):
            raise ExecutionRuleError("판독 영역의 화면 크기가 올바르지 않습니다.")
    for row in document.get("coordinates", []):
        if type(row) is not dict or set(row)-{"scene", "role", "when", "rect", "click_rect", "ocr_rect", "offset_ui", "size_ui", "enabled", "visible"}:
            raise ExecutionRuleError("좌표 교정 항목을 확인해 주세요.")
        if not all(isinstance(row.get(key), str) and row[key] for key in ("scene", "role")):
            raise ExecutionRuleError("좌표 교정에는 scene/role이 필요합니다.")
        if type(row.get("when", {})) is not dict:
            raise ExecutionRuleError("좌표 조건은 객체여야 합니다.")
        for key in ("rect", "click_rect", "ocr_rect"):
            if key in row and (type(row[key]) is not list or len(row[key]) != 4):
                raise ExecutionRuleError(key + "는 x/y/width/height 수식4개여야 합니다.")
        for key in ("offset_ui", "size_ui"):
            if key in row and (type(row[key]) is not list or len(row[key]) != 2 or
                    any(type(v) not in (int, float) for v in row[key])):
                raise ExecutionRuleError(key + "는 UI단위 숫자2개여야 합니다.")
        for key in ("enabled", "visible"):
            if key in row and type(row[key]) is not bool:
                raise ExecutionRuleError(key + "는 참/거짓이어야 합니다.")
    for row in document.get("fixed_targets", []):
        if type(row) is not dict or set(row)-{"id", "from", "to", "size"} or not all(key in row for key in ("from", "to")):
            raise ExecutionRuleError("고정 좌표 교정 항목을 확인해 주세요.")
        for key in ("from", "to"):
            if (type(row[key]) is not list or len(row[key]) not in (2, 4) or
                    any(type(v) not in (int, float) or not 0 <= v <= 1 for v in row[key])):
                raise ExecutionRuleError("고정 좌표는 정규화된 점/영역이어야 합니다.")
        if len(row["from"]) != len(row["to"]):
            raise ExecutionRuleError("고정 좌표 교정의 차원이 다릅니다.")
        if "size" in row and (type(row["size"]) is not list or len(row["size"]) != 2 or
                any(type(v) is not int or v <= 0 for v in row["size"])):
            raise ExecutionRuleError("고정 좌표의 화면 크기가 올바르지 않습니다.")
    return document


@dataclass(frozen=True)
class RuleSnapshot:
    revision: str
    digest: str
    values: object
    routes: object
    coordinates: tuple
    fixed_targets: tuple


@dataclass(frozen=True)
class ReloadResult:
    changed: bool = False
    revision: str = "builtin"
    error: str = ""
    geometry_changed: bool = False
    pending: bool = False


def snapshot_from(document, base=None):
    document = validate(document)
    values = dict(base.values) if base else {}
    routes = dict(base.routes) if base else {}
    for key, incoming in document.get("values", {}).items():
        if key in values:
            default = values[key]
            if (type(default) in (int, float) and type(incoming) not in (int, float) or
                    type(default) is bool and type(incoming) is not bool or
                    isinstance(default, (tuple, list)) and not isinstance(incoming, list) or
                    isinstance(default, str) and not isinstance(incoming, str)):
                raise ExecutionRuleError("규칙 자료형 불일치: " + key)
    values.update(document.get("values", {}))
    # Old exported defaults include1.25 explicitly and otherwise override the
    # new five-second default forever. Migrate only that retired stock value
    # in memory; preserve other explicit tuning and the user's original file.
    if values.get("focus.idle_seconds") == 1.25:
        values["focus.idle_seconds"] = 5.0
    routes.update(document.get("routes", {}))
    normalized = dict(schema=1, revision=document["revision"], values=_copy(values), routes=_copy(routes),
        coordinates=document.get("coordinates", []), fixed_targets=document.get("fixed_targets", []))
    digest = hashlib.sha256(json.dumps(normalized, sort_keys=True, ensure_ascii=True).encode()).hexdigest()
    return RuleSnapshot(document["revision"], digest, _freeze(normalized["values"]), _freeze(normalized["routes"]),
        _freeze(normalized["coordinates"]), _freeze(normalized["fixed_targets"]))


class ExecutionRules:
    def __init__(self, path=None, default_path=None, *, supports_coordinates=True):
        root = Path(os.environ["MASUKJA_MINI_DATA"]) if os.environ.get("MASUKJA_MINI_DATA") else install_dir().parent / DATA_FOLDER_NAME
        self.path = Path(path) if path is not None else root / "settings/ui-rules/execution.json"
        self.default_path = Path(default_path) if default_path is not None else resource_dir() / "ui-rules/execution-default.json"
        self.supports_coordinates = supports_coordinates
        self._lock = threading.RLock()
        self._busy = 0
        self._last_poll = -100.
        self._fingerprint = None
        self._failed_fingerprint = None
        self.error = ""
        self.pending = None
        self.geometry_paths = tuple(self.default_path.parent / name for name in
            ("fixed-geometry.json", "processing-geometry.json", "snack-geometry.json", "observation-geometry.json", "hud-geometry.json", "stage-geometry.json", "growth-guide-geometry.json", "hunt-geometry.json"))
        self.base = snapshot_from(dict(schema=1, revision="builtin", values={}))
        base_mark = self._base_mark()
        self.base = self._read_base()
        self._base_fingerprint = base_mark if self._base_mark() == base_mark else None
        self.active = self.base

    def _read_base(self):
        base = snapshot_from(dict(schema=1, revision="builtin", values={}))
        for path in (*self.geometry_paths, self.default_path):
            if path.is_file():
                base = snapshot_from(decode(path.read_bytes()), base)
        return base

    def _base_mark(self):
        return tuple((str(path), _file_mark(stat) if stat is not None else None)
                     for path in (*self.geometry_paths, self.default_path)
                     for stat in (_optional_stat(path),))

    @contextmanager
    def hold(self):
        with self._lock:
            self._busy += 1
        try:
            yield self.active
        finally:
            with self._lock:
                self._busy -= 1

    def refresh(self, *, force=False, adopt=True, prepare=None):
        """Poll cheaply; reject bad replacements without concealing the error.

        prepare(candidate) must build/validate the new coordinate container
        before it commits. Held input boundaries cannot adopt a new generation.
        """
        with self._lock:
            now = time.monotonic()
            if force or now-self._last_poll >= .5:
                self._last_poll = now
                fingerprint = None
                try:
                    path = plain_data_file(self.path)
                    stat = _optional_stat(path)
                    base_mark = self._base_mark()
                    fingerprint = (None if stat is None else _file_mark(stat), base_mark)
                    if force or (fingerprint != self._fingerprint and fingerprint != self._failed_fingerprint):
                        base = (self.base if not force and base_mark == self._base_fingerprint
                                else self._read_base())
                        if stat is None:
                            candidate = base
                        else:
                            if not 0 < stat.st_size <= MAX_BYTES:
                                raise ExecutionRuleError("실행 규칙 파일 크기가 올바르지 않습니다.")
                            raw = path.read_bytes()
                            end = path.stat()
                            if _file_mark(end) != fingerprint[0]:
                                return ReloadResult(revision=self.active.revision, pending=True)
                            candidate = snapshot_from(decode(raw), base)
                        if self._base_mark() != base_mark:
                            return ReloadResult(revision=self.active.revision, pending=True)
                        self._validate_support(candidate)
                        self.base = base
                        self._base_fingerprint = base_mark
                        self._fingerprint = fingerprint
                        self._failed_fingerprint = None
                        self.pending = candidate if candidate.digest != self.active.digest else None
                        self.error = ""
                    elif fingerprint == self._fingerprint and self._failed_fingerprint is not None:
                        # A rejected replacement was removed/restored to the
                        # last successfully loaded files. No stale error loop.
                        self._failed_fingerprint = None
                        self.error = ""
                except (OSError, ValueError, RuntimeError) as exc:
                    self.error = "실행 규칙 갱신 실패 · 기존 규칙 유지: " + str(exc)[:240]
                    self.pending = None
                    # Remember malformed *content*, not transient access/IO
                    # failures. A changed file/base mark or explicit refresh
                    # retries immediately; the active snapshot stays intact.
                    if isinstance(exc, ValueError) and fingerprint is not None:
                        self._failed_fingerprint = fingerprint
            if self.pending is None and adopt and not self._busy and prepare is not None:
                try:
                    if prepare(self.active):
                        return ReloadResult(True, self.active.revision, geometry_changed=True)
                except (ValueError, RuntimeError) as exc:
                    self.error = "좌표 규칙 적용 실패 · 기존 규칙 유지: " + str(exc)[:240]
            if self.pending is None or not adopt or self._busy:
                return ReloadResult(revision=self.active.revision, error=self.error, pending=self.pending is not None)
            candidate = self.pending
            geometric = (candidate.coordinates != self.active.coordinates or candidate.fixed_targets != self.active.fixed_targets)
            try:
                if prepare is not None:
                    prepare(candidate)
            except (ValueError, RuntimeError) as exc:
                self.error = "실행 규칙 적용 실패 · 기존 규칙 유지: " + str(exc)[:240]
                self.pending = None  # Retry only a changed file or explicit force.
                return ReloadResult(revision=self.active.revision, error=self.error)
            self.active, self.pending, self.error = candidate, None, ""
            return ReloadResult(True, candidate.revision, geometry_changed=geometric)

    def _validate_support(self, candidate):
        if candidate.coordinates and not self.supports_coordinates:
            raise ExecutionRuleError("22 고정 좌표판은 coordinates 대신 fixed_targets를 사용해 주세요.")

    def install(self, document):
        self._validate_support(snapshot_from(document, self.base))
        raw = json.dumps(document, ensure_ascii=False, indent=2).encode("utf-8")
        if len(raw) > MAX_BYTES:
            raise ExecutionRuleError("실행 규칙 파일이 너무 큽니다.")
        path = plain_data_file(self.path)
        path.parent.mkdir(parents=True, exist_ok=True)
        temporary = plain_data_file(path.with_name(path.name + ".pending"))
        created = False
        try:
            with temporary.open("xb") as stream:
                created = True
                stream.write(raw)
            os.replace(temporary, path)
        finally:
            if created and temporary.exists():
                temporary.unlink()
        return self.refresh(force=True, adopt=False)


_instance = None


def manager():
    global _instance
    if _instance is None:
        from . import __version__
        _instance = ExecutionRules(supports_coordinates=__version__ != "1.1.22")
    return _instance


def configure(path):
    global _instance
    if _instance is None or _instance.path != Path(path):
        from . import __version__
        _instance = ExecutionRules(path, supports_coordinates=__version__ != "1.1.22")
    return _instance


def snapshot():
    return _reading_snapshot.get() or manager().active


@contextmanager
def use_snapshot(current):
    token = _reading_snapshot.set(current)
    try:
        yield current
    finally:
        _reading_snapshot.reset(token)


def geometry_values(current):
    return {key: item for key, item in current.values.items()
            if key.startswith(("coordinates.", "layout.", "life.gather.calibration.")) or key.endswith(".padding_ui")}


def value(key, default=None):
    current = snapshot()
    return _copy(current.values[key]) if key in current.values else default


def geometry(key, *, image=None, runtime=None):
    """Read named geometry, never calculate it or supply an inline fallback.

    Captures and actuators share the prepared container. Preparation and
    standalone readers without a game frame use the same immutable rule data.
    """
    if runtime is not None:
        return runtime.prepared_geometry(key)
    values = getattr(image, "info", {}).get("prepared_geometry") if image is not None else None
    if values is None:
        values = snapshot().values
    name = "coordinates." + key
    try:
        return values[name]
    except KeyError as exc:
        raise ExecutionRuleError("외부 좌표 규칙에 항목이 없습니다: " + name) from exc


def number(key, default=0):
    result = value(key, default)
    if type(result) not in (int, float) or not math.isfinite(result):
        raise ExecutionRuleError("숫자 실행 규칙이 필요합니다: " + key)
    return result


def route(key, fallback_steps):
    current = snapshot()
    return _copy(current.routes[key]) if key in current.routes else fallback_steps


def refresh(**kwargs):
    return manager().refresh(**kwargs)


def stable_input(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        with manager().hold():
            return function(*args, **kwargs)
    return wrapped


def fixed_target(target, size):
    """Lookup only; external normalized fixed targets need no per-click formula."""
    if type(target) not in (tuple, list):
        return target
    for row in snapshot().fixed_targets:
        if tuple(row["from"]) == tuple(target) and ("size" not in row or tuple(row["size"]) == tuple(size)):
            return tuple(row["to"])
    return target


def observation_region(region, size):
    """Lookup a stored normalized ROI correction; no discovery or formula."""
    for row in snapshot().values.get("ocr.region_overrides", ()):
        if tuple(row["from"]) == tuple(region) and ("size" not in row or tuple(row["size"]) == tuple(size)):
            return tuple(row["to"])
    return region


def service_boundary(service, *, force=False):
    """One adoption point shared by dungeon, life, children and quick fishing."""
    if getattr(service, "_rules_boundary_active", False):
        return ReloadResult(revision=snapshot().revision)
    service._rules_boundary_active = True
    try:
        window = getattr(service, "window", None)
        runtime = getattr(window, "coordinate_runtime", None) or getattr(service, "_rule_runtime", None)
        def prepare(candidate):
            if window is None or runtime is None:
                return False
            atomic = getattr(runtime, "apply_external_rules", None)
            if callable(atomic):
                return atomic(window, candidate, force=True) if force else atomic(window, candidate)
            adopt = getattr(runtime, "adopt_execution_rules", None)
            if not callable(adopt):
                if candidate.coordinates:
                    raise ExecutionRuleError("고정 좌표판은 fixed_targets로 좌표를 지정해 주세요.")
                return False
            changed = adopt(window, candidate)
            return runtime.reload_external_pack(window, candidate) or changed
        result = refresh(force=force, prepare=prepare)
        notice = result.error or ("외부 실행 규칙 적용 · " + result.revision if result.changed else "")
        if notice and notice != getattr(service, "_last_rule_notice", None):
            service._last_rule_notice = notice
            status = getattr(service, "status", None)
            if callable(status):
                status(notice)
        return result
    finally:
        service._rules_boundary_active = False
