"""YAML loader for ``strategy.yaml`` with optional local override.

* Reads ``strategy.yaml`` (golden config).
* If ``strategy.local.yaml`` exists alongside, deep-merges its keys on
  top — that file is ``.gitignore``'d and used by Adriano for emergency
  overrides.
* Verifies ``config_hash`` matches the SHA-256 of the YAML *minus* the
  ``config_hash`` line itself. A mismatch is reported via
  :class:`ConfigHashError` and the orchestrator must arm the kill switch
  per ``docs/07-risk-controls.md``.

The loader does *not* depend on the runtime: it returns a validated
:class:`StrategyConfig` plus the computed hash; nothing else.
"""

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

from cerbero_bite.config.schema import StrategyConfig

__all__ = [
    "ConfigHashError",
    "LoadedConfig",
    "compute_config_hash",
    "load_strategy",
]


_HASH_KEY = "config_hash"


class ConfigHashError(RuntimeError):
    """Raised when the recorded ``config_hash`` does not match the file."""


@dataclass(frozen=True)
class LoadedConfig:
    """Result of :func:`load_strategy`."""

    config: StrategyConfig
    computed_hash: str
    sources: tuple[Path, ...]


def _strip_hash_line(text: str) -> str:
    """Return *text* with the ``config_hash:`` line replaced by an empty string.

    We deliberately keep the surrounding whitespace so that any other
    line numbers stay stable; only the value of the hash is removed.
    """
    out: list[str] = []
    for line in text.splitlines(keepends=True):
        stripped = line.lstrip()
        if stripped.startswith(f"{_HASH_KEY}:"):
            # keep the key but strip the value, so identical files with
            # different hashes still hash the same way
            indent = line[: len(line) - len(stripped)]
            out.append(f"{indent}{_HASH_KEY}:\n")
        else:
            out.append(line)
    return "".join(out)


def compute_config_hash(text: str) -> str:
    """SHA-256 of the YAML text after stripping the ``config_hash`` value."""
    canonical = _strip_hash_line(text).encode("utf-8")
    return hashlib.sha256(canonical).hexdigest()


def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    """Return ``base`` with values from ``override`` recursively merged in."""
    out = dict(base)
    for key, value in override.items():
        existing = out.get(key)
        if isinstance(existing, dict) and isinstance(value, dict):
            out[key] = _deep_merge(existing, value)
        else:
            out[key] = value
    return out


def _load_yaml(path: Path) -> dict[str, Any]:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if data is None:
        return {}
    if not isinstance(data, dict):
        raise ValueError(f"{path}: expected a top-level mapping")
    return data


def load_strategy(
    yaml_path: Path | str,
    *,
    local_override_path: Path | str | None = None,
    enforce_hash: bool = True,
) -> LoadedConfig:
    """Load and validate a strategy YAML, optionally merging a local file.

    Args:
        yaml_path: path to ``strategy.yaml``.
        local_override_path: when ``None`` (default), use
            ``<yaml_path>.local.yaml`` if present. Pass ``False`` /
            non-existent path to disable.
        enforce_hash: when ``True``, raise :class:`ConfigHashError` if
            the recorded hash does not match the file. Set to ``False``
            in test fixtures or right after a manual edit.
    """
    main_path = Path(yaml_path)
    text = main_path.read_text(encoding="utf-8")
    raw = _load_yaml(main_path)
    sources: list[Path] = [main_path]

    computed_hash = compute_config_hash(text)
    declared_hash = raw.get(_HASH_KEY)
    if enforce_hash and declared_hash != computed_hash:
        raise ConfigHashError(
            f"config_hash mismatch in {main_path}: "
            f"declared={declared_hash}, computed={computed_hash}"
        )

    if local_override_path is None:
        local_override_path = main_path.with_name(
            main_path.stem + ".local" + main_path.suffix
        )
    override_path = Path(local_override_path)
    if override_path.is_file():
        override = _load_yaml(override_path)
        raw = _deep_merge(raw, override)
        sources.append(override_path)

    return LoadedConfig(
        config=StrategyConfig(**raw),
        computed_hash=computed_hash,
        sources=tuple(sources),
    )
