"""Validate a hive overlay before any of it is applied.

The rule is all-or-nothing for the parts that describe behaviour, and
ignore-with-a-warning for the parts that describe the document:

  - an unknown top-level section, an unknown setting, an unknown profile or an
    unknown MCP server field is *forward compatibility* — a newer hive may
    know more than this instance. It is dropped and reported, never fatal;
  - a known field with the wrong type, an invalid route, or an MCP config that
    does not parse is a broken document. Applying the other half of it would
    leave the fleet in a state nobody wrote, so the whole document is
    rejected and the previous one (from cache) stays in force.

Missing fields are the normal case, not an error: they fall back to the local
config by definition of the overlay.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from navi.overlay.merge import merge_patch
from navi.overlay.schema import DENIED_SETTINGS, KNOWN_SECTIONS

_LLM_KEYS = {"targets", "routes", "probe"}
_TOOL_SCOPE_KEYS = {"agent", "subagent"}
_TOOL_SCOPE_INNER = {"native", "mcp"}


@dataclass
class ValidationResult:
    merged: dict = field(default_factory=dict)
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    unknown_fields: list[str] = field(default_factory=list)
    forbidden_fields: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return not self.errors


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def validate_envelope(layers: object) -> tuple[list[dict], list[str]]:
    """Check the transport shape; return the usable layers and warnings."""
    warnings: list[str] = []
    if not isinstance(layers, list):
        raise ValueError("'layers' must be a list")
    usable: list[dict] = []
    for index, layer in enumerate(layers):
        if not isinstance(layer, dict):
            raise ValueError(f"layer #{index} is not an object")
        name = layer.get("name", f"layer{index}")
        config = layer.get("config")
        if not isinstance(name, str) or not name:
            raise ValueError(f"layer #{index} needs a string 'name'")
        if not isinstance(config, dict):
            raise ValueError(f"layer '{name}' needs an object 'config'")
        usable.append({"name": name, "config": config})
    return usable, warnings


def validate_layers(base_settings, layers: list[dict]) -> ValidationResult:
    """Merge the layers and validate the result against the local base."""
    result = ValidationResult()
    merged: dict = {}
    for layer in layers:
        merged = merge_patch(merged, layer["config"])  # type: ignore[assignment]

    unknown_sections = sorted(set(merged) - KNOWN_SECTIONS)
    for name in unknown_sections:
        result.warnings.append(f"unknown section '{name}' ignored")
        merged.pop(name, None)

    if "settings" in merged:
        merged["settings"] = _clean_settings(base_settings, merged["settings"], result)
    if "profiles" in merged:
        merged["profiles"] = _clean_profiles(merged["profiles"], result)
    if "llm" in merged:
        merged["llm"] = _clean_llm(base_settings, merged["llm"], result)
    if "mcp_servers" in merged:
        merged["mcp_servers"] = _clean_mcp(merged["mcp_servers"], result)

    result.merged = {k: v for k, v in merged.items() if v}
    return result


# ── settings ────────────────────────────────────────────────────────────────


def _clean_settings(base_settings, patch: object, result: ValidationResult) -> dict:
    if not isinstance(patch, dict):
        result.errors.append("settings must be an object")
        return {}

    known = set(type(base_settings).model_fields)
    cleaned: dict = {}
    for name, value in patch.items():
        if name not in known:
            result.unknown_fields.append(f"settings.{name}")
            continue
        if name in DENIED_SETTINGS:
            result.forbidden_fields.append(f"settings.{name}")
            continue
        cleaned[name] = value

    try:
        type(base_settings)(**{**base_settings.model_dump(), **cleaned})
    except Exception as exc:  # validation error text names the bad field
        result.errors.append(f"settings: {exc}")
        return {}
    return cleaned


# ── profiles ────────────────────────────────────────────────────────────────


def _clean_profiles(patch: object, result: ValidationResult) -> dict:
    if not isinstance(patch, dict):
        result.errors.append("profiles must be an object")
        return {}

    from navi.profiles.base import AgentProfile
    from navi.profiles import ALL_PROFILES

    base_by_id = {p.id: p for p in ALL_PROFILES}
    known = set(AgentProfile.model_fields)
    cleaned: dict = {}
    for profile_id, profile_patch in patch.items():
        if profile_id not in base_by_id:
            result.warnings.append(f"unknown profile '{profile_id}' ignored")
            continue
        if not isinstance(profile_patch, dict):
            result.errors.append(f"profiles.{profile_id} must be an object")
            continue

        filtered: dict = {}
        for name, value in profile_patch.items():
            if name not in known:
                result.unknown_fields.append(f"profiles.{profile_id}.{name}")
                continue
            if name == "tools":
                value = _clean_tools(profile_id, value, result)
                if value is None:
                    continue
            filtered[name] = value
        if "id" in filtered and filtered["id"] != profile_id:
            result.errors.append(
                f"profiles.{profile_id}: 'id' cannot be changed ({filtered['id']!r})"
            )
            continue

        try:
            AgentProfile.model_validate(
                merge_patch(base_by_id[profile_id].model_dump(), filtered)
            )
        except Exception as exc:
            result.errors.append(f"profiles.{profile_id}: {exc}")
            continue
        if filtered:  # a profile patch that only held unknown fields says nothing
            cleaned[profile_id] = filtered
    return cleaned


def _clean_tools(profile_id: str, patch: object, result: ValidationResult) -> dict | None:
    if not isinstance(patch, dict):
        result.errors.append(f"profiles.{profile_id}.tools must be an object")
        return None
    out: dict = {}
    for scope, scope_patch in patch.items():
        if scope not in _TOOL_SCOPE_KEYS:
            result.unknown_fields.append(f"profiles.{profile_id}.tools.{scope}")
            continue
        if not isinstance(scope_patch, dict):
            result.errors.append(f"profiles.{profile_id}.tools.{scope} must be an object")
            continue
        inner: dict = {}
        for key, value in scope_patch.items():
            if key not in _TOOL_SCOPE_INNER:
                result.unknown_fields.append(
                    f"profiles.{profile_id}.tools.{scope}.{key}"
                )
                continue
            inner[key] = value
        out[scope] = inner
    return out


# ── llm ─────────────────────────────────────────────────────────────────────


def _clean_llm(base_settings, patch: object, result: ValidationResult) -> dict:
    if not isinstance(patch, dict):
        result.errors.append("llm must be an object")
        return {}

    filtered: dict = {}
    for key, value in patch.items():
        if key not in _LLM_KEYS:
            result.unknown_fields.append(f"llm.{key}")
            continue
        filtered[key] = value

    from navi.llm.config import LLMConfigError, load_llm_raw, parse_llm_config

    try:
        local_raw = load_llm_raw(base_settings.llm_config_file) or {}
    except Exception as exc:  # a broken local file must not block a fix from above
        result.warnings.append(f"local llm.json is invalid ({exc}); merged without it")
        local_raw = {}

    merged = merge_patch(local_raw, filtered)
    if not merged:
        return {}
    try:
        # Secrets are resolved when the backend is actually built; doing it
        # here would import the settings singleton while it is being created.
        parse_llm_config(merged, source="hive-overlay", resolve_secrets=False)
    except LLMConfigError as exc:
        result.errors.append(f"llm: {exc}")
        return {}
    return filtered


# ── MCP servers ─────────────────────────────────────────────────────────────


def _clean_mcp(patch: object, result: ValidationResult) -> dict:
    """Validate per-server patches against the local server definitions.

    A patch may define a server the machine does not have (the hive is the
    source of truth for the fleet) or override fields of one it has. It
    cannot *remove* one: ``null`` already means "drop this key" at the
    merge-patch level, so there is no value left here to act on. Removing a
    server stays a local-file operation.
    """
    if not isinstance(patch, dict):
        result.errors.append("mcp_servers must be an object")
        return {}

    from navi.mcp.config import McpServerConfig, load_mcp_servers

    local = {name: cfg.model_dump() for name, cfg in load_mcp_servers().items()}
    known = set(McpServerConfig.model_fields)
    cleaned: dict = {}

    for server, server_patch in patch.items():
        if not isinstance(server_patch, dict):
            result.errors.append(f"mcp_servers.{server} must be an object")
            continue
        filtered: dict = {}
        for key, value in server_patch.items():
            if key not in known:
                result.unknown_fields.append(f"mcp_servers.{server}.{key}")
                continue
            filtered[key] = value
        try:
            McpServerConfig.model_validate(merge_patch(local.get(server, {}), filtered))
        except Exception as exc:
            result.errors.append(f"mcp_servers.{server}: {exc}")
            continue
        cleaned[server] = filtered
    return cleaned
