"""Local, versioned recipe manifest.

A recipe is *data*: a named, versioned set of execution parameters handed to the
model backend. It carries no model- or vendor-specific code path — a recipe is
only ever valid once its own real forward has succeeded on this node
(see :mod:`meshnet_node.capability`).

The manifest ships with the node release. ``schema_version`` describes the file
layout this reader understands; ``catalogue_version`` identifies the recipe set
itself so a tracker can reason about which catalogue a node validated against.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from importlib.resources import files
from pathlib import Path
from typing import Any, Mapping

# Layout of recipes.json understood by this reader. Bump when the file shape changes.
RECIPE_SCHEMA_VERSION = 1

DEFAULT_RECIPE_ID = "baseline"

_MANIFEST_RESOURCE = "recipes.json"


class RecipeManifestError(ValueError):
    """Raised when a recipe manifest is missing, malformed, or unsupported.

    The message is operator-facing: it names the source and the fix, and never
    echoes raw file content back (a manifest may sit next to secrets in a
    misconfigured deployment).
    """


@dataclass(frozen=True)
class Recipe:
    """One named, versioned execution recipe."""

    id: str
    version: str
    backend_id: str
    description: str = ""
    params: Mapping[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "version": self.version,
            "backend_id": self.backend_id,
            "description": self.description,
            "params": dict(self.params),
        }


@dataclass(frozen=True)
class RecipeManifest:
    """A parsed, validated recipe catalogue."""

    schema_version: int
    catalogue_version: str
    recipes: tuple[Recipe, ...]
    source: str = "<memory>"

    def get(self, recipe_id: str) -> Recipe | None:
        for recipe in self.recipes:
            if recipe.id == recipe_id:
                return recipe
        return None

    def require(self, recipe_id: str) -> Recipe:
        """Return the named recipe, or raise listing what this catalogue offers."""
        recipe = self.get(recipe_id)
        if recipe is None:
            available = ", ".join(r.id for r in self.recipes) or "(none)"
            raise RecipeManifestError(
                f"unknown recipe {recipe_id!r} in {self.source}; "
                f"available recipes: {available}"
            )
        return recipe

    @property
    def ids(self) -> tuple[str, ...]:
        return tuple(r.id for r in self.recipes)

    def to_dict(self) -> dict:
        return {
            "schema_version": self.schema_version,
            "catalogue_version": self.catalogue_version,
            "recipes": [r.to_dict() for r in self.recipes],
        }


def _require_mapping(value: Any, what: str, source: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise RecipeManifestError(
            f"{what} in {source} must be a JSON object, got {type(value).__name__}"
        )
    return value


def _require_text(value: Any, what: str, source: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise RecipeManifestError(
            f"{what} in {source} must be a non-empty string"
        )
    return value


def _parse_recipe(raw: Any, index: int, source: str) -> Recipe:
    entry = _require_mapping(raw, f"recipes[{index}]", source)
    recipe_id = _require_text(entry.get("id"), f"recipes[{index}].id", source)
    version = _require_text(
        entry.get("version"), f"recipes[{recipe_id}].version", source
    )
    backend_id = _require_text(
        entry.get("backend_id"), f"recipes[{recipe_id}].backend_id", source
    )
    params = entry.get("params", {})
    if params is None:
        params = {}
    _require_mapping(params, f"recipes[{recipe_id}].params", source)
    description = entry.get("description", "")
    if not isinstance(description, str):
        raise RecipeManifestError(
            f"recipes[{recipe_id}].description in {source} must be a string"
        )
    return Recipe(
        id=recipe_id,
        version=version,
        backend_id=backend_id,
        description=description,
        params=dict(params),
    )


def parse_recipe_manifest(data: Any, source: str = "<memory>") -> RecipeManifest:
    """Validate an already-decoded manifest document."""
    doc = _require_mapping(data, "manifest root", source)

    if "schema_version" not in doc:
        raise RecipeManifestError(
            f"{source} is missing 'schema_version'; "
            f"this node reads recipe schema version {RECIPE_SCHEMA_VERSION}"
        )
    schema_version = doc["schema_version"]
    if not isinstance(schema_version, int) or isinstance(schema_version, bool):
        raise RecipeManifestError(
            f"'schema_version' in {source} must be an integer, "
            f"got {type(schema_version).__name__}"
        )
    if schema_version != RECIPE_SCHEMA_VERSION:
        raise RecipeManifestError(
            f"{source} declares recipe schema version {schema_version}, "
            f"but this node reads version {RECIPE_SCHEMA_VERSION}; "
            "upgrade the node or use a manifest for the supported version"
        )

    catalogue_version = _require_text(
        doc.get("catalogue_version"), "'catalogue_version'", source
    )

    raw_recipes = doc.get("recipes")
    if not isinstance(raw_recipes, list) or not raw_recipes:
        raise RecipeManifestError(
            f"'recipes' in {source} must be a non-empty JSON array"
        )

    recipes: list[Recipe] = []
    seen: set[str] = set()
    for index, raw in enumerate(raw_recipes):
        recipe = _parse_recipe(raw, index, source)
        if recipe.id in seen:
            raise RecipeManifestError(
                f"duplicate recipe id {recipe.id!r} in {source}; recipe ids must be unique"
            )
        seen.add(recipe.id)
        recipes.append(recipe)

    return RecipeManifest(
        schema_version=schema_version,
        catalogue_version=catalogue_version,
        recipes=tuple(recipes),
        source=source,
    )


def load_recipe_manifest(path: Path | None = None) -> RecipeManifest:
    """Load the packaged manifest, or one at ``path``.

    No network access and no remote catalogue: P0 recipes ship with the node.
    """
    if path is None:
        source = f"packaged {_MANIFEST_RESOURCE}"
        try:
            raw = files("meshnet_node").joinpath(_MANIFEST_RESOURCE).read_text(
                encoding="utf-8"
            )
        except (OSError, FileNotFoundError, ModuleNotFoundError) as exc:
            raise RecipeManifestError(
                f"{source} is missing from this node installation "
                f"({type(exc).__name__}); reinstall the node package"
            ) from exc
    else:
        source = str(path)
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError as exc:
            raise RecipeManifestError(
                f"cannot read recipe manifest {source}: {exc.strerror or exc}"
            ) from exc

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise RecipeManifestError(
            f"{source} is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}"
        ) from exc

    return parse_recipe_manifest(data, source=source)
