"""Read only the two persisted game UI preferences, without contacting the game.

``root`` is the directory containing BaseMMPlayerPrefs.txt and
ChangedMMPlayerPrefs.txt.  The default is the current user's LocalLow game
directory, derived from LOCALAPPDATA, never a hard-coded user profile.

The append journal is replayed over the INT section of the base file. Metadata
changes or explicit preparation refresh the cached immutable snapshot; neither a
previous good snapshot nor PC70 is substituted after a read/parse failure.
These are saved preferences, not proof of an active in-game UI instance.
"""
from __future__ import annotations

from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import re
import time


_STYLE_KEY = "GraphicsUserSetting.InterfaceStyleType"
_SCALE_KEY = "GraphicsUserSetting.InterfaceStyleScale"
_KEY_NAMES = {_STYLE_KEY: "style", _SCALE_KEY: "scale"}
_STYLE_NAMES = {0: "auto", 1: "pc", 2: "mobile"}
_PERCENTS = frozenset((70, 80, 90, 100))
_FILE_NAMES = ("BaseMMPlayerPrefs.txt", "ChangedMMPlayerPrefs.txt")
_SECTIONS = frozenset(("STRING", "INT", "FLOAT", "BOOL"))
_MAX_FILE_BYTES = 8 * 1024 * 1024
_SNAPSHOT_ATTEMPTS = 3
_INTEGER = re.compile(r"[+-]?[0-9]+\Z")
_KEY_PATTERN = r"GraphicsUserSetting\.InterfaceStyle(?:Type|Scale)"
_TARGET_START = re.compile(r"^(" + _KEY_PATTERN + r")(?=$|[\s:=])")
_JOURNAL_TARGET = re.compile(
    r"^(?:[+-][^:]*|[0-9]+)\s*:\s*(" + _KEY_PATTERN + r")(?=$|[\s:=])"
)
_BROKEN_JOURNAL_TARGET = re.compile(
    r"^[+-]?[0-9]+\s+(" + _KEY_PATTERN + r")(?=$|[\s:=])"
)
_JOURNAL_RECORD = re.compile(
    r"(?P<op>[+-])(?P<type>[0-9]+)\s*:\s*(?P<key>[^:]+?)"
    r"(?:\s*:\s*(?P<value>.*))?\Z"
)


@dataclass(frozen=True, slots=True)
class GameUiSettings:
    """A private-data-minimized immutable saved-settings observation.

    ``percent`` is the selected percentage for PC/Auto and the effective 100
    for Mobile.  ``auto`` remains explicit: a caller must resolve it using
    reviewed client rules, not silently assume PC.  ``signature`` depends only
    on the exposed style/percent, not file metadata or unrelated preferences.
    """

    style: str | None
    percent: int | None
    available: bool
    signature: str
    reason: str
    source_paths: tuple[str, ...]
    observed_at: float


@dataclass(frozen=True, slots=True)
class _TargetValue:
    value: int | None = None
    error: str = ""


class _SnapshotFailure(Exception):
    """Internal reason code only; never include source contents in errors."""


def _identity(stat: os.stat_result) -> tuple[int, ...]:
    # On Windows, Python's path-stat ctime can mean creation time while fstat
    # ctime means change time.  birthtime is consistent across both handles.
    created = getattr(stat, "st_birthtime_ns", stat.st_ctime_ns)
    return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, created)


def _target(line: str, *, journal: bool) -> str | None:
    # Look only in a record's KEY position, not in unrelated string values.
    match = _TARGET_START.match(line)
    if match is None and journal:
        match = _JOURNAL_TARGET.match(line) or _BROKEN_JOURNAL_TARGET.match(line)
    return match.group(1) if match else None


def _integer_value(key: str, raw: str) -> _TargetValue:
    name = _KEY_NAMES[key]
    raw = raw.strip()
    if len(raw) > 12 or not _INTEGER.fullmatch(raw):
        return _TargetValue(error=f"{name}_malformed")
    value = int(raw)
    supported = value in _STYLE_NAMES if key == _STYLE_KEY else value in _PERCENTS
    if not supported:
        return _TargetValue(error=f"{name}_unsupported")
    return _TargetValue(value=value)


def _parse_base(text: str) -> dict[str, _TargetValue]:
    values: dict[str, _TargetValue] = {}
    section = ""
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if line in _SECTIONS:
            section = line
            continue
        key = _target(line, journal=False)
        if key is None:
            continue
        name = _KEY_NAMES[key]
        if section != "INT":
            values[key] = _TargetValue(error=f"{name}_type_unsupported")
            continue
        left, separator, right = line.partition(":")
        if not separator or left.strip() != key:
            values[key] = _TargetValue(error=f"{name}_malformed")
            continue
        values[key] = _integer_value(key, right)
    return values


def _apply_journal(text: str, values: dict[str, _TargetValue]) -> None:
    for raw_line in text.splitlines():
        line = raw_line.strip()
        key = _target(line, journal=True)
        if key is None:
            continue
        name = _KEY_NAMES[key]
        record = _JOURNAL_RECORD.fullmatch(line)
        if line.startswith("-"):
            values[key] = _TargetValue(error=f"{name}_deleted")
        elif record is None or record.group("key").strip() != key:
            values[key] = _TargetValue(error=f"{name}_malformed")
        elif record.group("type") != "2":
            # Journal type 2 is INT; it is distinct from UI style 2 (Mobile).
            values[key] = _TargetValue(error=f"{name}_type_unsupported")
        elif record.group("value") is None:
            values[key] = _TargetValue(error=f"{name}_malformed")
        else:
            values[key] = _integer_value(key, record.group("value"))


def _decode(data: bytes) -> str:
    try:
        if data.startswith((b"\xff\xfe", b"\xfe\xff")):
            return data.decode("utf-16")
        return data.decode("utf-8-sig")
    except UnicodeError:
        raise _SnapshotFailure("source_encoding_unsupported") from None


class GameUiSettingsReader:
    """Fresh read-only preference reader; no input, process, CLI or DPI access."""

    def __init__(self, root: Path | None = None):
        if root is None:
            local_appdata = os.environ.get("LOCALAPPDATA", "")
            local_path = Path(local_appdata) if local_appdata else None
            root = (local_path.parent / "LocalLow" / "devCAT" / "마비노기 모바일"
                    if local_path is not None and local_path.is_absolute() else None)
        self.root = Path(root) if root is not None else None
        self._cached = None
        self._cached_mark = None

    def _mark(self):
        if self.root is None:
            return None
        try:
            return tuple(_identity((self.root / name).stat()) for name in _FILE_NAMES)
        except OSError:
            return None

    def read(self, force: bool = False) -> GameUiSettings:
        """Read changed preferences once; input consumers reuse the snapshot.

        This is saved client information, not a live memory/UI-instance probe.
        The inexpensive file mark is checked on every read; absence or failure
        never substitutes an old value. Screen confirmation forces a reread.
        """
        mark = self._mark()
        if not force and mark is not None and mark == self._cached_mark and self._cached is not None:
            return self._cached
        result = self._read_uncached()
        if result.available and mark is not None and mark == self._mark():
            self._cached, self._cached_mark = result, mark
        else:
            self._cached = self._cached_mark = None
        return result

    def _read_uncached(self) -> GameUiSettings:
        """Return a new saved-preference observation.

        File absence, sharing/access errors, unsupported/malformed target values
        and unstable writes return ``available=False`` with no old values.
        Only stable generic reason codes and the two known source paths leave
        this module; unrelated preferences and exception messages never do.
        """
        paths = tuple(self.root / name for name in _FILE_NAMES) if self.root is not None else ()
        if not paths:
            return self._result(None, None, "profile_location_unavailable", paths)
        try:
            base, changed = self._snapshot(paths)
            values = _parse_base(_decode(base))
            _apply_journal(_decode(changed), values)
            for key in (_STYLE_KEY, _SCALE_KEY):
                entry = values.get(key)
                if entry is None:
                    return self._result(None, None, f"{_KEY_NAMES[key]}_missing", paths)
                if entry.error:
                    return self._result(None, None, entry.error, paths)
            style = _STYLE_NAMES[values[_STYLE_KEY].value]
            percent = 100 if style == "mobile" else values[_SCALE_KEY].value
            return self._result(style, percent, "", paths)
        except _SnapshotFailure as error:
            return self._result(None, None, str(error), paths)
        except (OSError, ValueError):
            # Invalid paths and filesystem errors contain potentially private
            # details, so the original exception text is deliberately omitted.
            return self._result(None, None, "source_unreadable", paths)

    def _snapshot(self, paths: tuple[Path, ...]) -> tuple[bytes, bytes]:
        for _ in range(_SNAPSHOT_ATTEMPTS):
            try:
                before = tuple(path.stat() for path in paths)
                if any(stat.st_size > _MAX_FILE_BYTES for stat in before):
                    raise _SnapshotFailure("source_too_large")
                contents: list[bytes] = []
                stable = True
                for path, expected in zip(paths, before):
                    with path.open("rb") as source:
                        opened = os.fstat(source.fileno())
                        if _identity(opened) != _identity(expected):
                            stable = False
                            break
                        data = source.read(_MAX_FILE_BYTES + 1)
                        ended = os.fstat(source.fileno())
                    if len(data) > _MAX_FILE_BYTES:
                        raise _SnapshotFailure("source_too_large")
                    if _identity(ended) != _identity(expected) or len(data) != expected.st_size:
                        stable = False
                        break
                    contents.append(data)
                after = tuple(path.stat() for path in paths)
                if stable and all(_identity(a) == _identity(b) for a, b in zip(before, after)):
                    return contents[0], contents[1]
            except FileNotFoundError:
                # A temporary rename may settle on the next bounded attempt.
                # Persistent absence never reuses a previously successful read.
                missing = True
                continue
            missing = False
        raise _SnapshotFailure("source_missing" if missing else "source_changed_during_read")

    @staticmethod
    def _result(style: str | None, percent: int | None, reason: str,
                paths: tuple[Path, ...]) -> GameUiSettings:
        semantic = f"game-ui-settings-v1:{style}:{percent}".encode("ascii")
        return GameUiSettings(style, percent, not reason, hashlib.sha256(semantic).hexdigest(),
                              reason, tuple(str(path) for path in paths), time.monotonic())
