"""Tool namespace resolver — the engine side of `_meta/tool-namespaces`.

The skill YAML at ``_meta/tool-namespaces/config/namespaces.yaml`` owns
the strategy: which tools belong to which namespace, which keywords
signal that a namespace is relevant to a turn, which namespaces are
always-on, and how many keyword-matched namespaces to include. This
module is the engine — it loads the YAML, maps tool names to their
namespace, and ranks namespaces by keyword coverage over a turn text.

Rule-12 compliance: every value a user might reasonably want to tune
lives in the YAML. Hardcoded fallbacks marked ``# arch:deterministic``
exist only so first boot / corrupted YAML never crashes tool filtering.

The resolver is stateless and sync — safe to call from async code
without blocking (no IO after init). Load once at server start,
keep in ``app.state.tool_namespace_resolver``.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)


# arch:deterministic — fallback namespace config when the skill YAML is
# missing or malformed. Minimal taxonomy so the system still boots and
# filtering still has a meaningful shape; real vocabulary lives in YAML.
_FALLBACK_NAMESPACES: dict[str, Any] = {
    "always_on": ["memory", "orchestration", "system"],
    "default_top_k": 2,
    "misc_namespace": "misc",
    "namespaces": {
        "misc": {
            "description": "Unlisted tools — always included.",
            "tools": [],
            "keywords": [],
        }
    },
}


@dataclass
class NamespaceResolver:
    """Loads the namespace YAML and answers relevance queries.

    Instantiate once at startup; readers can share freely (no mutation
    after load). All lookups are O(1) or O(n) over a small tool count.
    """

    tool_to_namespace: dict[str, str] = field(default_factory=dict)
    namespace_keywords: dict[str, list[str]] = field(default_factory=dict)
    namespace_descriptions: dict[str, str] = field(default_factory=dict)
    always_on: frozenset[str] = field(default_factory=frozenset)
    default_top_k: int = 2
    misc_namespace: str = "misc"

    @classmethod
    def from_skills_dir(cls, skills_dir: Path | None) -> NamespaceResolver:
        """Construct from the `_meta/tool-namespaces/config/namespaces.yaml`.

        Any missing/malformed section falls back to `_FALLBACK_NAMESPACES`.
        Never raises — the resolver is safe to use immediately after
        construction in all failure modes.
        """
        data = _load_yaml(skills_dir) or _FALLBACK_NAMESPACES
        namespaces = data.get("namespaces") or _FALLBACK_NAMESPACES["namespaces"]
        if not isinstance(namespaces, dict) or not namespaces:
            namespaces = _FALLBACK_NAMESPACES["namespaces"]

        tool_to_namespace: dict[str, str] = {}
        namespace_keywords: dict[str, list[str]] = {}
        namespace_descriptions: dict[str, str] = {}
        for ns_name, block in namespaces.items():
            if not isinstance(block, dict):
                continue
            desc = block.get("description") or ""
            tools = block.get("tools") or []
            keywords = block.get("keywords") or []
            namespace_descriptions[str(ns_name)] = str(desc)
            namespace_keywords[str(ns_name)] = [str(k).lower() for k in keywords if k]
            for tool in tools:
                if tool:
                    tool_to_namespace[str(tool)] = str(ns_name)

        raw_always = data.get("always_on") or _FALLBACK_NAMESPACES["always_on"]
        if not isinstance(raw_always, list):
            raw_always = _FALLBACK_NAMESPACES["always_on"]
        always_on = frozenset(str(x) for x in raw_always if x)

        raw_top_k = data.get("default_top_k", _FALLBACK_NAMESPACES["default_top_k"])
        try:
            top_k = int(raw_top_k)
        except (TypeError, ValueError):
            top_k = _FALLBACK_NAMESPACES["default_top_k"]
        if top_k < 0:
            top_k = _FALLBACK_NAMESPACES["default_top_k"]

        misc = data.get("misc_namespace") or _FALLBACK_NAMESPACES["misc_namespace"]

        log.debug(
            "Namespace resolver loaded: %d namespaces, %d tools mapped, always_on=%s",
            len(namespace_descriptions),
            len(tool_to_namespace),
            sorted(always_on),
        )
        return cls(
            tool_to_namespace=tool_to_namespace,
            namespace_keywords=namespace_keywords,
            namespace_descriptions=namespace_descriptions,
            always_on=always_on,
            default_top_k=top_k,
            misc_namespace=str(misc),
        )

    def namespace_for(self, tool_name: str) -> str:
        """Return the namespace for a tool, or misc_namespace if unlisted."""
        return self.tool_to_namespace.get(tool_name, self.misc_namespace)

    def all_namespaces(self) -> frozenset[str]:
        """All namespace names declared in the YAML plus misc_namespace."""
        return frozenset({*self.namespace_descriptions.keys(), self.misc_namespace})

    def select_relevant(
        self,
        text: str,
        *,
        top_k: int | None = None,
    ) -> frozenset[str]:
        """Rank namespaces by keyword match over `text`, return a set.

        The returned set ALWAYS contains ``always_on`` plus ``misc_namespace``
        (so unlisted tools are never silently hidden), plus the top-k
        namespaces by keyword hit count. Ties are broken by declaration
        order for determinism.

        Empty text or no hits → returns just ``always_on + misc``.
        """
        k = self.default_top_k if top_k is None else max(0, int(top_k))
        selected: set[str] = set(self.always_on) | {self.misc_namespace}

        if not text or k == 0:
            return frozenset(selected)

        lowered = text.lower()
        # Score each namespace by number of distinct keyword hits.
        # Single-word keywords match as WORD-START prefixes (`\btask`
        # matches "task", "tasks", "tasked", but not "multitask") — this
        # handles English plurals and inflections without maintaining
        # two forms of every keyword in the YAML. YAML authors should
        # declare the singular/base form and trust the matcher.
        # Multi-word keywords match as literal substrings (not prefixes)
        # since phrases tend to be idiomatic and shouldn't inflate.
        scores: list[tuple[int, str]] = []
        for ns_name, keywords in self.namespace_keywords.items():
            if not keywords:
                continue
            hits = 0
            for kw in keywords:
                if " " in kw:
                    if kw in lowered:
                        hits += 1
                else:
                    pattern = rf"\b{re.escape(kw)}"
                    if re.search(pattern, lowered):
                        hits += 1
            if hits > 0:
                scores.append((hits, ns_name))

        # Sort by hit count descending, then alphabetically for tie-breaking.
        scores.sort(key=lambda pair: (-pair[0], pair[1]))
        for _, ns_name in scores[:k]:
            selected.add(ns_name)

        return frozenset(selected)

    def filter_tool_names(
        self,
        tool_names: list[str],
        allowed_namespaces: frozenset[str],
    ) -> list[str]:
        """Return only those tool_names whose namespace is in `allowed_namespaces`.

        Preserves input order. Misc namespace is ALWAYS treated as allowed
        regardless of the set, so newly-registered tools without a mapping
        aren't accidentally hidden during rollout.
        """
        effective = allowed_namespaces | {self.misc_namespace}
        return [t for t in tool_names if self.namespace_for(t) in effective]


def _load_yaml(skills_dir: Path | None) -> dict | None:
    """Read `_meta/tool-namespaces/config/namespaces.yaml`, returning the
    parsed dict or None if unavailable or malformed.
    """
    if not skills_dir:
        return None
    path = skills_dir / "_meta" / "tool-namespaces" / "config" / "namespaces.yaml"
    if not path.is_file():
        return None
    try:
        import yaml

        data = yaml.safe_load(path.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            return None
        return data
    except Exception:
        log.warning("Failed to load namespaces.yaml, using fallback", exc_info=True)
        return None
