"""Konfiguration.

1. validate_config(backends): Validierung von Backend-Objekten (Altvertrag).
2. load_gateway_config(path): strikt validierte JSON-Konfigurationsdatei des
   Gateways. Unbekannte Schlüssel, falsche Typen oder unsichere Werte führen
   zu ConfigurationError (fail-closed, kein stiller Default).
"""
from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Optional

from .budget import BudgetLimit
from .core import Backend, PrivacyMatrix, PrivacyTier

ALLOWED_BACKENDS = frozenset({"codex", "claude"})
ALLOWED_CAPABILITIES = frozenset({
    "text", "code", "vision", "files"
})
ALLOWED_TASKS = frozenset({
    "analysis", "coding", "writing",
    "vision", "planning", "review"
})
ALLOWED_MODES = frozenset({"shadow", "execute"})
MAX_CONFIG_BYTES = 1024 * 1024
_SENDER_ID = re.compile(r"^-?\d{1,20}$")


class ConfigurationError(ValueError):
    pass


@dataclass(frozen=True)
class ValidatedConfig:
    version: int
    backends: tuple[Backend, ...]


def validate_config(
    backends: list[Backend],
    version: int = 1
) -> ValidatedConfig:

    if type(version) is not int or version != 1:
        raise ConfigurationError("Unsupported version")

    if not backends:
        raise ConfigurationError("No backends configured")

    names = set()

    for backend in backends:
        if backend.name not in ALLOWED_BACKENDS:
            raise ConfigurationError("Unknown backend")

        if backend.name in names:
            raise ConfigurationError("Duplicate backend")

        names.add(backend.name)

        if not isinstance(backend.quality_score, int) \
                or isinstance(backend.quality_score, bool) \
                or not 0 <= backend.quality_score <= 100:
            raise ConfigurationError("Invalid quality score")

        if not backend.capabilities.issubset(
            ALLOWED_CAPABILITIES
        ):
            raise ConfigurationError("Unknown capability")

        if not backend.supported_tasks.issubset(
            ALLOWED_TASKS
        ):
            raise ConfigurationError("Unknown task")

        if type(backend.available) is not bool:
            raise ConfigurationError("Invalid availability")

        if type(backend.budget_allowed) is not bool:
            raise ConfigurationError("Invalid budget flag")

        if backend.max_context_bytes is not None and (
                type(backend.max_context_bytes) is not int or backend.max_context_bytes < 1):
            raise ConfigurationError("Invalid context limit")

        for task, score in backend.task_quality:
            if task not in ALLOWED_TASKS or type(score) is not int or not 0 <= score <= 100:
                raise ConfigurationError("Invalid task quality")

    return ValidatedConfig(version, tuple(backends))


# ---------------------------------------------------------------------------
# Gateway-Konfigurationsdatei
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class ClearanceSettings:
    key_file: Path
    ttl_seconds: int = 120
    max_clock_skew_seconds: int = 5


@dataclass(frozen=True)
class AuthSettings:
    allowed_sender_ids: Optional[frozenset[str]]
    telegram_access_file: Optional[Path]


@dataclass(frozen=True)
class PrivacySettings:
    matrix: PrivacyMatrix
    claude_status_quo_confidential: bool
    public_marker_enabled: bool
    codex_parity_with_claude: bool = False
    extra_confidential_keywords: tuple[str, ...] = ()
    extra_internal_keywords: tuple[str, ...] = ()


@dataclass(frozen=True)
class RoutingSettings:
    min_quality: int = 0
    manual_fallback: str = "block"
    default_task_class: str = "analysis"


@dataclass(frozen=True)
class BackendSettings:
    name: str
    enabled: bool
    capabilities: frozenset[str]
    tasks: frozenset[str]
    quality: int
    task_quality: tuple[tuple[str, int], ...]
    max_context_bytes: Optional[int]


@dataclass(frozen=True)
class HealthSettings:
    failure_threshold: int = 3
    open_seconds: int = 300


@dataclass(frozen=True)
class LimitSettings:
    max_text_bytes: int = 65536
    max_reply_chars: int = 12000
    max_attachments: int = 1


@dataclass(frozen=True)
class CodexSettings:
    real_execution: bool
    command_template: tuple[str, ...]
    isolation_wrapper: tuple[str, ...]
    timeout_seconds: int
    max_output_bytes: int
    workspace_root: Optional[Path]
    env: tuple[tuple[str, str], ...]
    resource_limits: Optional[Any]
    prlimit_path: str
    profile: str = "text"
    codex_auth_writable: bool = False
    auth_file: Optional[Path] = None
    home_root: Optional[Path] = None
    user_bus: tuple[tuple[str, str], ...] = ()
    tools_protect_startup: bool = True
    # E44 Option 2 (Kais TG 12923): einzige Home-Ordner, in die das tools-Profil schreiben darf.
    tools_write_allowlist: tuple[str, ...] = ()


@dataclass(frozen=True)
class GatewayConfig:
    version: int
    allowed_modes: frozenset[str]
    state_dir: Path
    clearance: ClearanceSettings
    auth: AuthSettings
    privacy: PrivacySettings
    routing: RoutingSettings
    backends: tuple[BackendSettings, ...]
    budget: Mapping[str, BudgetLimit]
    health: HealthSettings
    dedupe_ttl_seconds: int
    limits: LimitSettings
    codex: CodexSettings
    decision_log_retention_days: int = 0
    source: Optional[Path] = field(default=None, compare=False)

    def backend(self, name: str) -> Optional[BackendSettings]:
        for settings in self.backends:
            if settings.name == name:
                return settings
        return None


def _keys(data: Any, where: str, allowed: set[str], required: set[str] = frozenset()) -> Mapping:
    if not isinstance(data, Mapping):
        raise ConfigurationError(f"{where}: object expected")
    unknown = set(data) - allowed
    if unknown:
        raise ConfigurationError(f"{where}: unknown key {sorted(unknown)[0]}")
    missing = set(required) - set(data)
    if missing:
        raise ConfigurationError(f"{where}: missing key {sorted(missing)[0]}")
    return data


def _int(value: Any, where: str, low: int, high: int) -> int:
    if type(value) is not int or not low <= value <= high:
        raise ConfigurationError(f"{where}: integer {low}..{high} expected")
    return value


def _bool(value: Any, where: str) -> bool:
    if type(value) is not bool:
        raise ConfigurationError(f"{where}: boolean expected")
    return value


def _abs_path(value: Any, where: str) -> Path:
    if not isinstance(value, str) or not value or "\x00" in value or not os.path.isabs(value):
        raise ConfigurationError(f"{where}: absolute path expected")
    return Path(value)


def _str_list(value: Any, where: str, *, max_items: int = 128, max_len: int = 4096) -> tuple[str, ...]:
    if not isinstance(value, list) or len(value) > max_items:
        raise ConfigurationError(f"{where}: list expected")
    for item in value:
        if not isinstance(item, str) or not item or len(item) > max_len or "\x00" in item:
            raise ConfigurationError(f"{where}: non-empty strings expected")
    return tuple(value)


def _tiers(value: Any, where: str) -> frozenset[PrivacyTier]:
    names = _str_list(value, where, max_items=4, max_len=32)
    tiers = set()
    for name in names:
        try:
            tier = PrivacyTier(name)
        except ValueError as exc:
            raise ConfigurationError(f"{where}: unknown privacy tier {name}") from exc
        if tier is PrivacyTier.CONFIDENTIAL:
            raise ConfigurationError(
                f"{where}: confidential is never configured in the matrix; "
                "only privacy.claude_status_quo_confidential may admit it for claude")
        tiers.add(tier)
    return frozenset(tiers)


def _parse_privacy(data: Any) -> PrivacySettings:
    section = _keys(data, "privacy", {"matrix", "claude_status_quo_confidential", "public_marker_enabled",
                                      "codex_parity_with_claude",
                                      "extra_confidential_keywords", "extra_internal_keywords"},
                    {"matrix", "claude_status_quo_confidential"})
    matrix_raw = _keys(section["matrix"], "privacy.matrix", set(ALLOWED_BACKENDS))
    mapping = {name: _tiers(value, f"privacy.matrix.{name}") for name, value in matrix_raw.items()}
    status_quo = _bool(section["claude_status_quo_confidential"], "privacy.claude_status_quo_confidential")
    if status_quo:
        mapping["claude"] = frozenset(mapping.get("claude", frozenset())) | {PrivacyTier.CONFIDENTIAL}
    # Owner-Entscheidung 24.10.2026 (TG 12852): Codex darf dieselben Datenklassen wie Claude.
    # Der Schalter übernimmt Claudes Freigaben vollständig; matrix.codex muss dann fehlen,
    # damit keine zweite, widersprüchliche Codex-Regel in der Konfiguration steht.
    parity = _bool(section.get("codex_parity_with_claude", False), "privacy.codex_parity_with_claude")
    if parity:
        if "codex" in matrix_raw:
            raise ConfigurationError(
                "privacy.matrix.codex must be omitted when privacy.codex_parity_with_claude is true")
        mapping["codex"] = frozenset(mapping.get("claude", frozenset()))
    return PrivacySettings(
        matrix=PrivacyMatrix.from_mapping(mapping),
        claude_status_quo_confidential=status_quo,
        public_marker_enabled=_bool(section.get("public_marker_enabled", True), "privacy.public_marker_enabled"),
        codex_parity_with_claude=parity,
        extra_confidential_keywords=_str_list(section.get("extra_confidential_keywords", []),
                                              "privacy.extra_confidential_keywords", max_len=100),
        extra_internal_keywords=_str_list(section.get("extra_internal_keywords", []),
                                          "privacy.extra_internal_keywords", max_len=100),
    )


def _parse_backends(data: Any) -> tuple[BackendSettings, ...]:
    section = _keys(data, "backends", set(ALLOWED_BACKENDS))
    if not section:
        raise ConfigurationError("backends: at least one backend required")
    result = []
    for name in sorted(section):
        where = f"backends.{name}"
        entry = _keys(section[name], where, {"enabled", "capabilities", "tasks", "quality", "task_quality",
                                             "max_context_bytes"}, {"enabled", "capabilities", "tasks", "quality"})
        capabilities = frozenset(_str_list(entry["capabilities"], f"{where}.capabilities", max_len=32))
        if not capabilities <= ALLOWED_CAPABILITIES:
            raise ConfigurationError(f"{where}.capabilities: unknown capability")
        tasks = frozenset(_str_list(entry["tasks"], f"{where}.tasks", max_len=32))
        if not tasks <= ALLOWED_TASKS:
            raise ConfigurationError(f"{where}.tasks: unknown task")
        task_quality_raw = _keys(entry.get("task_quality", {}), f"{where}.task_quality", set(ALLOWED_TASKS))
        task_quality = tuple(sorted(
            (task, _int(score, f"{where}.task_quality.{task}", 0, 100)) for task, score in task_quality_raw.items()
        ))
        max_context = entry.get("max_context_bytes")
        if max_context is not None:
            max_context = _int(max_context, f"{where}.max_context_bytes", 1, 64 * 1024 * 1024)
        result.append(BackendSettings(
            name=name, enabled=_bool(entry["enabled"], f"{where}.enabled"), capabilities=capabilities,
            tasks=tasks, quality=_int(entry["quality"], f"{where}.quality", 0, 100),
            task_quality=task_quality, max_context_bytes=max_context,
        ))
    return tuple(result)


def _parse_budget(data: Any, backends: tuple[BackendSettings, ...]) -> dict[str, BudgetLimit]:
    section = _keys(data, "budget", set(ALLOWED_BACKENDS))
    limits = {}
    for name, value in section.items():
        entry = _keys(value, f"budget.{name}", {"daily_jobs", "daily_token_estimate"}, {"daily_jobs"})
        tokens = entry.get("daily_token_estimate")
        if tokens is not None:
            tokens = _int(tokens, f"budget.{name}.daily_token_estimate", 1, 10 ** 12)
        limits[name] = BudgetLimit(_int(entry["daily_jobs"], f"budget.{name}.daily_jobs", 0, 10 ** 6), tokens)
    for backend in backends:
        if backend.enabled and backend.name not in limits:
            raise ConfigurationError(f"budget.{backend.name}: required for enabled backend")
    return limits


def _parse_codex(data: Any, *, state_dir: Optional[Path] = None) -> CodexSettings:
    from .codex_adapter import DEFAULT_COMMAND_TEMPLATE, validate_command_template
    from .isolation import PKG_REPO_ROOT
    from .isolation.profiles import WriteAllowlistError, validate_write_allowlist
    from .runner import ResourceLimits, UnsafeCommandError, validate_env

    section = _keys(data, "codex", {"real_execution", "command_template", "isolation_wrapper", "timeout_seconds",
                                    "max_output_bytes", "workspace_root", "env", "resource_limits",
                                    "prlimit_path", "profile", "codex_auth_writable", "auth_file",
                                    "home_root", "user_bus", "tools_protect_startup", "tools_write_allowlist"})
    real = _bool(section.get("real_execution", False), "codex.real_execution")
    profile = section.get("profile", "text")
    if profile not in ("text", "tools"):
        raise ConfigurationError("codex.profile: text or tools expected")
    allow_bypass = profile == "tools"
    template = _str_list(section.get("command_template", list(DEFAULT_COMMAND_TEMPLATE)), "codex.command_template")
    wrapper = _str_list(section.get("isolation_wrapper", []), "codex.isolation_wrapper")
    try:
        # tools-Profil: Bypass-Tokens nur mit echtem Kaefig (systemd-run + iso_exec) davor.
        validate_command_template(template, wrapper, allow_bypass_sandbox=allow_bypass, require_cage=allow_bypass)
    except UnsafeCommandError as exc:
        raise ConfigurationError(f"codex.command_template: {exc.code}") from exc
    env_raw = section.get("env", {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"})
    if not isinstance(env_raw, dict):
        raise ConfigurationError("codex.env: object expected")
    try:
        env = validate_env(dict(env_raw))
    except UnsafeCommandError as exc:
        raise ConfigurationError(f"codex.env: {exc.code}") from exc
    workspace = section.get("workspace_root")
    workspace = _abs_path(workspace, "codex.workspace_root") if workspace is not None else None
    limits_raw = section.get("resource_limits")
    limits = None
    if limits_raw is not None:
        entry = _keys(limits_raw, "codex.resource_limits",
                      {"address_space_bytes", "cpu_seconds", "max_file_bytes", "max_open_files"})
        limits = ResourceLimits(**{k: _int(v, f"codex.resource_limits.{k}", 1, 2 ** 62) for k, v in entry.items()})
    prlimit_path = section.get("prlimit_path", "/usr/bin/prlimit")
    _abs_path(prlimit_path, "codex.prlimit_path")
    auth_file = _abs_path(section.get("auth_file", "/home/aria/.codex/auth.json"), "codex.auth_file")
    home_root = _abs_path(section.get("home_root", "/home/aria"), "codex.home_root")
    codex_auth_writable = _bool(section.get("codex_auth_writable", False), "codex.codex_auth_writable")
    # Owner-Entscheidung TG 12861 (24.10.2026): Default TRUE (Startdateien/Hooks/Brain read-only).
    tools_protect_startup = _bool(section.get("tools_protect_startup", True), "codex.tools_protect_startup")
    # Owner-Entscheidung E44 Option 2 (TG 12923, 25.10.2026): das tools-Profil liest das Home, schreibt
    # aber nur im Lauf-Arbeitsbereich und in diesen ausdruecklich benannten Projektordnern. Default leer.
    # Jeder Eintrag wird hier fail-closed geprueft (absolut, kanonisch, existierendes Verzeichnis ohne
    # Symlink, echt unter home_root, keine versteckte Komponente, nicht in Sperr-/Startschutzbereichen,
    # state_dir, laufendem Repo/aria-v3-*, nicht ueberlappend mit workspace_root) und je Lauf erneut.
    allow_raw = _str_list(section.get("tools_write_allowlist", []), "codex.tools_write_allowlist")
    if allow_raw and profile != "tools":
        raise ConfigurationError("codex.tools_write_allowlist: only valid for codex.profile tools")
    try:
        write_allowlist = validate_write_allowlist(
            list(allow_raw), home_root=str(home_root),
            state_dir=str(state_dir) if state_dir is not None else None, repo_root=PKG_REPO_ROOT,
            runs_root=str(workspace) if workspace is not None else None)
    except WriteAllowlistError as exc:
        raise ConfigurationError(f"codex.tools_write_allowlist: {exc}") from exc
    bus_raw = section.get("user_bus", {})
    if not isinstance(bus_raw, dict):
        raise ConfigurationError("codex.user_bus: object expected")
    _keys(bus_raw, "codex.user_bus", {"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "PATH"})
    for k, v in bus_raw.items():
        if not isinstance(v, str) or not v or "\x00" in v:
            raise ConfigurationError(f"codex.user_bus.{k}: non-empty string expected")
    if real:
        if not wrapper:
            raise ConfigurationError("codex.real_execution requires codex.isolation_wrapper")
        if workspace is None:
            raise ConfigurationError("codex.real_execution requires codex.workspace_root")
        if limits is None:
            raise ConfigurationError("codex.real_execution requires codex.resource_limits")
    if profile == "tools" and limits is not None and limits.address_space_bytes is not None:
        # V8 im codex-code-mode-host reserviert für seine Sandbox weit mehr als 64 GiB virtuellen
        # Adressraum; mit RLIMIT_AS stirbt jeder Werkzeugaufruf (SIGTRAP). Speicher begrenzt
        # im tools-Profil der Scope (MemoryMax), nicht RLIMIT_AS.
        raise ConfigurationError("codex.resource_limits.address_space_bytes must be omitted for codex.profile tools")
    return CodexSettings(
        real_execution=real, command_template=template, isolation_wrapper=wrapper,
        timeout_seconds=_int(section.get("timeout_seconds", 120), "codex.timeout_seconds", 1, 3600),
        max_output_bytes=_int(section.get("max_output_bytes", 2 * 1024 * 1024), "codex.max_output_bytes",
                              1, 16 * 1024 * 1024),
        workspace_root=workspace, env=tuple(sorted(env.items())), resource_limits=limits,
        prlimit_path=prlimit_path, profile=profile, codex_auth_writable=codex_auth_writable,
        auth_file=auth_file, home_root=home_root, user_bus=tuple(sorted(bus_raw.items())),
        tools_protect_startup=tools_protect_startup, tools_write_allowlist=write_allowlist,
    )


TOP_LEVEL_KEYS = {"version", "allowed_modes", "state_dir", "clearance", "auth", "privacy", "routing",
                  "backends", "budget", "health", "dedupe_ttl_seconds", "limits", "codex", "_comment",
                  "decision_log_retention_days"}


def parse_gateway_config(data: Any, *, source: Optional[Path] = None) -> GatewayConfig:
    top = _keys(data, "config", TOP_LEVEL_KEYS,
                {"version", "allowed_modes", "state_dir", "clearance", "auth", "privacy", "backends", "budget"})
    if type(top["version"]) is not int or top["version"] != 1:
        raise ConfigurationError("config.version: 1 expected")
    modes = frozenset(_str_list(top["allowed_modes"], "config.allowed_modes", max_items=2, max_len=16))
    if not modes or not modes <= ALLOWED_MODES:
        raise ConfigurationError("config.allowed_modes: subset of shadow/execute expected")

    clearance_raw = _keys(top["clearance"], "clearance", {"key_file", "ttl_seconds", "max_clock_skew_seconds"},
                          {"key_file"})
    clearance = ClearanceSettings(
        key_file=_abs_path(clearance_raw["key_file"], "clearance.key_file"),
        ttl_seconds=_int(clearance_raw.get("ttl_seconds", 120), "clearance.ttl_seconds", 1, 600),
        max_clock_skew_seconds=_int(clearance_raw.get("max_clock_skew_seconds", 5),
                                    "clearance.max_clock_skew_seconds", 0, 60),
    )

    auth_raw = _keys(top["auth"], "auth", {"allowed_sender_ids", "telegram_access_file"})
    sender_ids = None
    if "allowed_sender_ids" in auth_raw:
        sender_ids = frozenset(_str_list(auth_raw["allowed_sender_ids"], "auth.allowed_sender_ids", max_len=21))
        if any(not _SENDER_ID.match(s) for s in sender_ids):
            raise ConfigurationError("auth.allowed_sender_ids: numeric Telegram ids expected")
    access_file = None
    if "telegram_access_file" in auth_raw:
        access_file = _abs_path(auth_raw["telegram_access_file"], "auth.telegram_access_file")
    if sender_ids is None and access_file is None:
        raise ConfigurationError("auth: allowed_sender_ids or telegram_access_file required")

    routing_raw = _keys(top.get("routing", {}), "routing", {"min_quality", "manual_fallback", "default_task_class"})
    fallback = routing_raw.get("manual_fallback", "block")
    if fallback not in ("block", "checked_fallback"):
        raise ConfigurationError("routing.manual_fallback: block or checked_fallback expected")
    default_task = routing_raw.get("default_task_class", "analysis")
    if default_task not in ALLOWED_TASKS:
        raise ConfigurationError("routing.default_task_class: unknown task")
    routing = RoutingSettings(_int(routing_raw.get("min_quality", 0), "routing.min_quality", 0, 100),
                              fallback, default_task)

    backends = _parse_backends(top["backends"])
    health_raw = _keys(top.get("health", {}), "health", {"failure_threshold", "open_seconds"})
    limits_raw = _keys(top.get("limits", {}), "limits", {"max_text_bytes", "max_reply_chars", "max_attachments"})
    state_dir = _abs_path(top["state_dir"], "config.state_dir")
    return GatewayConfig(
        version=1,
        allowed_modes=modes,
        state_dir=state_dir,
        clearance=clearance,
        auth=AuthSettings(sender_ids, access_file),
        privacy=_parse_privacy(top["privacy"]),
        routing=routing,
        backends=backends,
        budget=_parse_budget(top["budget"], backends),
        health=HealthSettings(
            _int(health_raw.get("failure_threshold", 3), "health.failure_threshold", 1, 100),
            _int(health_raw.get("open_seconds", 300), "health.open_seconds", 1, 86400),
        ),
        dedupe_ttl_seconds=_int(top.get("dedupe_ttl_seconds", 604800), "config.dedupe_ttl_seconds",
                                60, 30 * 86400),
        limits=LimitSettings(
            _int(limits_raw.get("max_text_bytes", 65536), "limits.max_text_bytes", 1, 1024 * 1024),
            _int(limits_raw.get("max_reply_chars", 12000), "limits.max_reply_chars", 100, 100000),
            _int(limits_raw.get("max_attachments", 1), "limits.max_attachments", 0, 10),
        ),
        codex=_parse_codex(top.get("codex", {}), state_dir=state_dir),
        decision_log_retention_days=_int(top.get("decision_log_retention_days", 0),
                                         "config.decision_log_retention_days", 0, 3650),
        source=source,
    )


def load_gateway_config(path) -> GatewayConfig:
    path = Path(path)
    try:
        with open(path, "rb") as handle:
            raw = handle.read(MAX_CONFIG_BYTES + 1)
    except OSError as exc:
        raise ConfigurationError("CONFIG_UNREADABLE") from exc
    if len(raw) > MAX_CONFIG_BYTES:
        raise ConfigurationError("CONFIG_TOO_LARGE")
    try:
        data = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, ValueError) as exc:
        raise ConfigurationError("CONFIG_INVALID_JSON") from exc
    return parse_gateway_config(data, source=path)
