"""Manifest lookup table.

Each MCP app (network, protect, access) ships a ``tools_manifest.json`` that
is generated by ``scripts/generate_tool_manifest.py``. This module loads them
at startup and exposes :meth:`ManifestRegistry.resolve` for the Phase 2 action
dispatcher (Task 12).

Manifest shape (as of Phase 2 Task 11)
--------------------------------------
The manifests are dicts with this top-level shape::

    {
      "count": <int>,
      "generated_by": "scripts/generate_tool_manifest.py",
      "module_map": { "<tool_name>": "<dotted.module.path>", ... },
      "tools": [
        {
          "name": "<tool_name>",
          "description": "...",
          "permission_action": "create" | "read" | "update" | "delete" | ...,
          "permission_category": "<category>",
          "schema": { "input": { ... } },
          "annotations": { ... }
        },
        ...
      ]
    }

The manifests do **not** currently emit ``manager`` (the manager class) or
``method`` (the manager method to invoke) — they only describe the tool's
public contract. Task 12 (the action dispatcher) will need that mapping; for
this task we populate :class:`ToolEntry.manager` and :class:`ToolEntry.method`
as empty strings and leave the resolution mechanism to be decided alongside
Task 12 once concrete dispatcher requirements are in hand.

The ``category`` field is sourced from ``permission_category`` — that is the
authoritative grouping the manifest uses today.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from importlib.resources import files
from typing import Any

from unifi_mcp_shared.meta_tools import is_meta_tool as _is_mcp_meta_tool

_PRODUCTS: tuple[str, ...] = ("network", "protect", "access")


class ToolNotFound(Exception):
    """Raised when :meth:`ManifestRegistry.resolve` is given an unknown tool name."""


@dataclass(frozen=True)
class ToolEntry:
    """Resolved manifest entry for a single tool.

    ``manager`` and ``method`` are intentionally empty strings for now — the
    manifests do not carry that information. Task 12 will fill the gap.
    """

    name: str
    product: str
    category: str
    manager: str
    method: str


class ManifestRegistry:
    """Lookup table over the merged ``tools_manifest.json`` from each MCP app."""

    def __init__(self, entries: dict[str, ToolEntry]) -> None:
        self._entries = entries

    @classmethod
    def load_from_apps(cls, products: tuple[str, ...] = _PRODUCTS) -> "ManifestRegistry":
        """Load manifests from each MCP app package and build a lookup table.

        Apps that aren't installed in the current environment (or whose
        manifest is missing) are silently skipped — this lets the API run
        against any subset of the three products.
        """

        entries: dict[str, ToolEntry] = {}
        for product in products:
            pkg_name = f"unifi_{product}_mcp"
            try:
                manifest_path = files(pkg_name) / "tools_manifest.json"
                data = json.loads(manifest_path.read_text())
            except (ModuleNotFoundError, FileNotFoundError):
                continue
            entries.update(_parse_manifest(data, product))
        return cls(entries)

    def has(self, name: str) -> bool:
        return name in self._entries

    def resolve(self, name: str) -> ToolEntry:
        entry = self._entries.get(name)
        if entry is None:
            raise ToolNotFound(name)
        return entry

    def all_categories_for_product(self, product: str) -> set[str]:
        return {e.category for e in self._entries.values() if e.product == product}

    def all_tools(self) -> list[str]:
        """Return the registered tool names (sorted for deterministic output)."""
        return sorted(self._entries.keys())

    def __len__(self) -> int:
        return len(self._entries)


def _parse_manifest(data: Any, product: str) -> dict[str, ToolEntry]:
    """Extract :class:`ToolEntry` records from a single product's manifest.

    Tolerates older/alternate shapes by falling back to ``module_map`` when
    the ``tools`` array is missing — a tool's category is then derived from
    the trailing component of the module path (``...tools.<category>``).
    """

    entries: dict[str, ToolEntry] = {}
    tools = data.get("tools") if isinstance(data, dict) else None
    module_map = data.get("module_map", {}) if isinstance(data, dict) else {}

    if isinstance(tools, list):
        for tool in tools:
            if not isinstance(tool, dict):
                continue
            name = tool.get("name")
            if not isinstance(name, str) or not name:
                continue
            if _is_mcp_meta_tool(name):
                continue
            category = tool.get("permission_category")
            if not isinstance(category, str) or not category:
                category = _category_from_module(module_map.get(name, ""))
            entries[name] = ToolEntry(
                name=name,
                product=product,
                category=category,
                manager="",
                method="",
            )
        return entries

    # Fallback: module_map only.
    if isinstance(module_map, dict):
        for name, module_path in module_map.items():
            if not isinstance(name, str) or not name:
                continue
            entries[name] = ToolEntry(
                name=name,
                product=product,
                category=_category_from_module(module_path or ""),
                manager="",
                method="",
            )
    return entries


def _category_from_module(module_path: str) -> str:
    """Derive a category from a dotted module path like ``unifi_x_mcp.tools.<cat>``."""

    if not module_path:
        return ""
    return module_path.rsplit(".", 1)[-1]
