"""Config documents for the fleet: shape, layer selection, hashing.

A document is a list of layers, each with a name, a ``config`` merge-patch and
an optional ``match`` selector::

    {"layers": [
      {"name": "global", "config": {"settings": {"gnauth_client_id": "..."}}},
      {"name": "lab", "match": {"tags": ["lab"]},
       "config": {"settings": {"llm_complete_timeout": 120}}},
      {"name": "yuki", "match": {"names": ["yuki"]},
       "config": {"profiles": {"assistant": {"temperature": 0.2}}}}
    ]}

Layers apply in document order over the instance's local config; a layer with
no ``match`` (or an empty one) applies to everyone. ``enabled: false`` keeps a
layer in the history but out of the fleet.

The hash is computed over the **layers an instance actually receives**, not
over the whole document, and with exactly the same canonicalisation the
instance uses (``navi/overlay/util.py``). Publishing a layer that targets one
machine therefore leaves every other machine's hash untouched — otherwise each
publish would make the whole fleet re-ack identical content.
"""

from __future__ import annotations

import hashlib
import json

_MATCH_KEYS = {"names", "instance_ids", "tags"}


class ConfigError(ValueError):
    """A published document is not usable. Reported as HTTP 400."""


def canonical_json(value: object) -> str:
    # Keep in lockstep with navi/overlay/util.py — the two hashes are compared.
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def content_hash(value: object) -> str:
    digest = hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


def _string_list(value: object, where: str) -> list[str]:
    if value is None:
        return []
    if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
        raise ConfigError(f"{where} must be a list of non-empty strings")
    return list(value)


def validate_layers(raw: object) -> list[dict]:
    """Normalise a published document; raise ``ConfigError`` on bad shape."""
    if not isinstance(raw, list):
        raise ConfigError("'layers' must be a list")
    # An empty list is a real publish: it clears the fleet overlay and every
    # instance falls back to its local config.

    seen: set[str] = set()
    layers: list[dict] = []
    for index, layer in enumerate(raw):
        if not isinstance(layer, dict):
            raise ConfigError(f"layer #{index} is not an object")
        name = layer.get("name")
        if not isinstance(name, str) or not name.strip():
            raise ConfigError(f"layer #{index} needs a non-empty 'name'")
        if name in seen:
            raise ConfigError(f"duplicate layer name {name!r}")
        seen.add(name)

        config = layer.get("config")
        if not isinstance(config, dict):
            raise ConfigError(f"layer {name!r}: 'config' must be an object")

        match_raw = layer.get("match") or {}
        if not isinstance(match_raw, dict):
            raise ConfigError(f"layer {name!r}: 'match' must be an object")
        unknown = set(match_raw) - _MATCH_KEYS
        if unknown:
            raise ConfigError(
                f"layer {name!r}: unknown match keys {sorted(unknown)} "
                f"(known: {sorted(_MATCH_KEYS)})"
            )
        match = {
            key: _string_list(match_raw.get(key), f"layer {name!r}: match.{key}")
            for key in _MATCH_KEYS
            if match_raw.get(key) is not None
        }
        if not any(match.values()):
            match = {}

        enabled = layer.get("enabled", True)
        if not isinstance(enabled, bool):
            raise ConfigError(f"layer {name!r}: 'enabled' must be true or false")

        layers.append(
            {"name": name, "config": config, "match": match, "enabled": enabled}
        )
    return layers


def _matches(layer: dict, name: str, instance_id: str, tags: list[str]) -> bool:
    match = layer.get("match") or {}
    if not match:  # a global layer
        return True
    if name and name in match.get("names", []):
        return True
    if instance_id and instance_id in match.get("instance_ids", []):
        return True
    return bool(set(tags) & set(match.get("tags", [])))


def effective_layers(
    layers: list[dict],
    *,
    name: str = "",
    instance_id: str = "",
    tags: list[str] | None = None,
) -> list[dict]:
    """The layers this instance receives, in document order, sans metadata."""
    tags = tags or []
    return [
        {"name": layer["name"], "config": layer["config"]}
        for layer in layers
        if layer.get("enabled", True) and _matches(layer, name, instance_id, tags)
    ]


def effective_hash(
    layers: list[dict],
    *,
    name: str = "",
    instance_id: str = "",
    tags: list[str] | None = None,
) -> str:
    return content_hash(
        effective_layers(layers, name=name, instance_id=instance_id, tags=tags)
    )
