"""Skill loader: discovers, loads, and matches SKILL.md files.

Skill folder structure:
  system/skills/{category}/{name}/
  ├── SKILL.md           ← required: frontmatter (name, triggers, etc.) + instructions
  ├── templates/         ← optional: reusable templates (email, report, etc.)
  ├── examples/          ← optional: few-shot examples for the LLM
  ├── references/        ← optional: background docs the skill can reference
  └── config.md          ← optional: skill-specific settings
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path

import frontmatter

log = logging.getLogger(__name__)


@dataclass
class Skill:
    """A loaded skill definition."""

    name: str
    title: str
    description: str
    triggers: list[str] = field(default_factory=list)
    category: str = ""
    status: str = "active"
    body: str = ""
    path: str = ""
    # Additional files in the skill folder
    files: list[str] = field(default_factory=list)
    # Extended fields
    author: str = ""
    tools_granted: list[str] = field(default_factory=list)
    depends_on: list[str] = field(default_factory=list)
    priority: int = 0
    model_tier: str = ""  # "reasoning", "capable", "fast", or "" (inherit)
    examples: list[dict] = field(default_factory=list)  # [{input, output}]
    output_format: str = ""
    # ── Versioning + provenance (spec follow-up) ──
    # Semver-style version string ("1.0.0"). Skills without a version
    # default to "0.0.0". Used by skill school and by user-visible
    # version-history queries.
    version: str = "0.0.0"
    # Optional signature blob (e.g. sha256:... of the canonicalized body).
    # Future work: verify against a trusted signer on load. Present now
    # so skill files can start carrying the field without a schema migration.
    signature: str = ""
    # Optional publisher identity (email, handle, or "user"). Default "user"
    # means the skill was authored in this vault and has no external origin.
    publisher: str = "user"
    # Content hash — sha256 of the skill body computed on load. Used to
    # detect tampering: if the stored `signature` doesn't match this hash,
    # the skill has been modified since signing.
    content_hash: str = ""
    # Arbitrary YAML config blocks (e.g., language, filters, thresholds)
    # — opaque to the loader, read by the owning service.
    config: dict = field(default_factory=dict)
    # Filesystem mtime of SKILL.md, epoch seconds. Populated on load. Used
    # by the UI to sort by "latest imported/edited" and to surface recently
    # touched skills. Zero when unavailable (unlikely).
    modified_at: float = 0.0
    # ── Semantic retrieval cache (Voyager-style, 2026-04-24) ──
    # Opaque embedding vector for `{description} {triggers}`. Populated
    # by SkillLoader.set_embedder() when a VaultEmbeddings provider is
    # wired; consumed by find_matching_skills() for cosine similarity.
    # Stored as a plain list[float] (not numpy.ndarray) so the Skill
    # dataclass stays serializable and numpy stays an optional dep.
    embedding: list[float] | None = None


class SkillLoader:
    """Discovers and loads SKILL.md files from the skills directory."""

    def __init__(self, skills_dir: Path) -> None:
        self._skills_dir = skills_dir
        self._skills: dict[str, Skill] = {}
        self._loaded = False
        self._full_prompt_cache: str | None = None
        # Semantic-retrieval support (Voyager-style, 2026-04-24). Set
        # post-init via `set_embedder()` once VaultEmbeddings is wired.
        # When None, `find_matching_skills()` returns `[]` (fail-open)
        # and no skill embeddings are computed.
        self._embedder: object | None = None

    def load_all(self) -> dict[str, Skill]:
        """Scan skills directory and load all SKILL.md files."""
        self._skills.clear()
        self._full_prompt_cache = None

        if not self._skills_dir.is_dir():
            log.info("Skills directory not found: %s", self._skills_dir)
            self._loaded = True
            return self._skills

        for skill_file in self._skills_dir.rglob("SKILL.md"):
            try:
                self._load_skill(skill_file)
            except Exception:
                log.warning("Failed to load skill: %s", skill_file)

        self._loaded = True
        # Validate depends_on references
        for sname, skill in self._skills.items():
            for dep in skill.depends_on:
                if dep not in self._skills:
                    log.warning("Skill '%s' depends on unknown skill '%s'", sname, dep)
        log.debug("Loaded %d skills from %s", len(self._skills), self._skills_dir)
        return self._skills

    @property
    def skills(self) -> dict[str, Skill]:
        if not self._loaded:
            self.load_all()
        return self._skills

    def get_skill(self, name: str) -> Skill | None:
        """Get a skill by name."""
        return self.skills.get(name)

    def get_system_prompt(self, skill_name: str) -> str | None:
        """Get the System Prompt section from a skill's body.

        Skills define LLM prompts in a ``## System Prompt`` section.
        Returns the content of that section, or the full body as fallback.
        """
        skill = self.get_skill(skill_name)
        if not skill or not skill.body:
            return None

        # Try to extract ## System Prompt section
        marker = "## System Prompt"
        idx = skill.body.find(marker)
        if idx == -1:
            # Fallback: use entire body as the prompt
            return skill.body.strip()

        # Extract from marker to next ## heading or end
        content = skill.body[idx + len(marker):]
        # Find next ## heading (not a subsection)
        next_heading = content.find("\n## ")
        if next_heading != -1:
            content = content[:next_heading]
        return content.strip()

    def get_skill_section(self, skill_name: str, section: str) -> str | None:
        """Get a named section (e.g. '## Week') from a skill's body.

        Returns content between the heading and the next same-level heading,
        or None if not found.
        """
        skill = self.get_skill(skill_name)
        if not skill or not skill.body:
            return None

        marker = f"## {section}"
        idx = skill.body.find(marker)
        if idx == -1:
            return None

        content = skill.body[idx + len(marker):]
        next_heading = content.find("\n## ")
        if next_heading != -1:
            content = content[:next_heading]
        return content.strip()

    def get_skill_config(self, skill_name: str, block: str) -> dict:
        """Get a YAML config block from a skill's frontmatter.

        Example:
            # in SKILL.md:
            language:
              output_policy: source
              fallback_language: en

            # in Python:
            cfg = loader.get_skill_config("kb-ingest", "language")
            cfg["output_policy"]  # "source"

        Returns an empty dict if the skill or the block doesn't exist.
        Never raises.
        """
        skill = self.get_skill(skill_name)
        if not skill:
            return {}
        cfg = skill.config.get(block)
        return cfg if isinstance(cfg, dict) else {}

    def load_skill_config_file(self, skill_name: str, filename: str) -> object:
        """Load a YAML file from a skill's ``config/`` directory.

        This is the Rule 12 strategy loader — services call it to read
        their strategy YAML without knowing how skills are laid out on
        disk. Search order mirrors how the rest of the skill system
        resolves files:

          1. ``{skill.path}/config/{filename}``  — the skill's own config dir,
             which lives next to SKILL.md. Users editing their vault copy
             override the bundled default transparently because the
             skills directory itself is already vault-first.

        Returns the parsed YAML contents (usually dict or list), or
        ``None`` if the file doesn't exist or can't be parsed. Never raises.

        Example::

            # defaults/skills/core/kb-ingest/config/junk-patterns.yaml
            en:
              - "none mentioned"
              - "tbd"
            sv:
              - "inget nämnt"

            # in Python:
            data = loader.load_skill_config_file("kb-ingest", "junk-patterns.yaml")
            # → {"en": ["none mentioned", "tbd"], "sv": ["inget nämnt"]}

        Services that need a flat multilingual keyword list should
        continue to use ``infra.language_patterns.load_keywords`` which
        already knows how to flatten ``{lang: [...]}`` into a single
        lowercased tuple. This helper is the generic counterpart for
        everything that isn't a keyword list — thresholds, tables,
        rules, templates, decision trees.
        """
        skill = self.get_skill(skill_name)
        if not skill or not skill.path:
            return None
        # `skill.path` is relative to `self._skills_dir` and points at the
        # SKILL.md file itself — e.g. ``core/kb-ingest/SKILL.md``. We resolve
        # the sibling ``config/`` dir by taking the parent and joining back
        # under the skills directory root.
        skill_rel = Path(skill.path)
        if skill_rel.name == "SKILL.md":
            skill_rel = skill_rel.parent
        config_path = self._skills_dir / skill_rel / "config" / filename
        if not config_path.is_file():
            return None
        try:
            import yaml  # type: ignore
        except ImportError:
            log.warning("PyYAML not installed — cannot load %s", config_path)
            return None
        try:
            with open(config_path, encoding="utf-8") as f:
                return yaml.safe_load(f)
        except Exception as e:
            log.warning("Failed to load skill config %s: %s", config_path, e)
            return None

    def resolve_skill_language(
        self,
        skill_name: str,
        source_text: str,
        *,
        source_meta: dict | None = None,
        global_policy: str | None = None,
        user_locale: str = "en",
    ) -> tuple[str, str, str, str, str]:
        """Resolve the three-language chain for any skill that has a `language:` block.

        Spec §19 item 2 — per-skill language config for skills outside of
        kb-ingest (memory-consolidate, daily-reflection, weekly-reflection,
        journal generation, etc.). Returns (content_lang, output_lang,
        effective_policy, policy_source, directive) so services can inject
        the directive into their system prompt without reimplementing the
        chain.

        Falls back gracefully if the skill has no language block: returns
        the detected source language with policy="source" and a matching
        directive.

        Never raises.
        """
        from agntspace.infra.language import (
            build_language_directive,
            resolve_languages,
        )
        skill_cfg = self.get_skill_config(skill_name, "language")
        try:
            content_lang, output_lang, policy, policy_source = resolve_languages(
                source_text,
                source_meta=source_meta,
                skill_config=skill_cfg,
                kb_schema=None,
                global_policy=global_policy,
                user_locale=user_locale,
            )
            directive = build_language_directive(content_lang, output_lang)
            return content_lang, output_lang, policy, policy_source, directive
        except Exception:
            # Never break the caller on a language-resolution failure
            return "en", "en", "source", "default", ""

    def get_skill_rules(self, skill_name: str) -> str:
        """Get the Quality Rules and Rules sections from a skill body.

        These are appended to LLM prompts as additional constraints.
        """
        skill = self.get_skill(skill_name)
        if not skill or not skill.body:
            return ""

        rules_parts: list[str] = []
        for section in ("## Quality Rules", "## Rules"):
            idx = skill.body.find(section)
            if idx != -1:
                content = skill.body[idx:]
                # Find next ## heading that isn't a subsection
                lines = content.split("\n")
                end = len(lines)
                for i, line in enumerate(lines[1:], 1):
                    if line.startswith("## ") and not line.startswith("### "):
                        end = i
                        break
                rules_parts.append("\n".join(lines[:end]).strip())

        return "\n\n".join(rules_parts)

    def match_skills(self, text: str) -> list[Skill]:
        """Find skills that match user input based on triggers."""
        lower = text.lower()
        matches = []
        for skill in self.skills.values():
            if skill.status != "active":
                continue
            for trigger in skill.triggers:
                if trigger.lower() in lower:
                    matches.append(skill)
                    break
        return matches

    # ─── Semantic retrieval (Voyager-style, 2026-04-24) ───

    def set_embedder(self, embedder: object | None) -> None:
        """Wire an embedding provider and compute skill embeddings in bulk.

        The provider must expose a `_get_model()` method returning an
        object with ``.embed(list[str]) -> iterable[iterable[float]]``
        (mirrors the VaultEmbeddings shape — OllamaEmbedModel today,
        any fastembed-compatible stub in tests). Passing ``None`` clears
        the embedder and any cached skill embeddings.

        Never raises — any provider failure (Ollama unreachable, model
        not pulled, network hiccup) leaves all skill embeddings at
        ``None`` and the loader falls back to priority-ordered
        non-semantic paths exactly as before.
        """
        self._embedder = embedder
        if embedder is None:
            for skill in self._skills.values():
                skill.embedding = None
            return
        self._compute_skill_embeddings()

    def _skill_embedding_text(self, skill: Skill) -> str:
        """Assemble the text we embed for a skill.

        Description is the load-bearing signal (authored to describe what
        the skill DOES), triggers are secondary but helpful for short
        task queries ("run wiki job" → matches a skill whose description
        is vague but trigger is exact). Title adds diversity.

        Category is deliberately EXCLUDED — it's metadata (``core`` /
        ``_meta`` / ``creative``), not a semantic descriptor. A skill
        that only has a category and nothing else returns ``""`` so the
        caller skips embedding it entirely — matching against "core"
        would be noise, not signal.
        """
        parts = [skill.title, skill.description]
        if skill.triggers:
            parts.append(" ".join(skill.triggers))
        return " ".join(p for p in parts if p).strip()

    def _compute_skill_embeddings(self) -> None:
        """Batch-embed every active skill's descriptor text. Fail-open."""
        if self._embedder is None:
            return
        try:
            model = self._embedder._get_model()  # noqa: SLF001 — mirrors VaultEmbeddings shape
        except Exception:
            log.debug("SkillLoader: embedder model unavailable", exc_info=True)
            return

        active = [s for s in self._skills.values() if s.status == "active"]
        # Skip empty descriptors — embedding an empty string is wasteful
        # and most embedding models return zero vectors or error on them.
        indexed = [(i, s) for i, s in enumerate(active) if self._skill_embedding_text(s)]
        if not indexed:
            return

        texts = [self._skill_embedding_text(s) for _, s in indexed]
        try:
            vectors = list(model.embed(texts))
        except Exception:
            log.debug("SkillLoader: batch embed failed", exc_info=True)
            return

        if len(vectors) != len(indexed):
            log.warning(
                "SkillLoader: embedder returned %d vectors for %d skills — skipping cache",
                len(vectors), len(indexed),
            )
            return

        for (_, skill), vec in zip(indexed, vectors, strict=False):
            try:
                # Coerce to plain list[float] for dataclass serializability
                # (cache file, /api/skills). Works for both Ollama's
                # list[float] and any fastembed-shaped numpy ndarray in tests.
                skill.embedding = [float(x) for x in vec]
            except Exception:
                skill.embedding = None

    def find_matching_skills(
        self,
        task_text: str,
        top_k: int = 5,
        scope: list[str] | None = None,
        min_score: float = 0.0,
    ) -> list[tuple[str, float]]:
        """Return the top_k skills most semantically related to ``task_text``.

        Args:
            task_text: free-form task description or user message.
            top_k: maximum number of matches to return.
            scope: optional allowlist of skill names; when provided, only
                these are considered. Useful when a subagent has a fixed
                skill set and we want top-k within that set.
            min_score: cosine-similarity floor; hits below this are dropped.
                Defaults to 0 so NO filtering — callers set a threshold
                when they care about quality over quantity.

        Returns:
            List of ``(skill_name, cosine_score)`` tuples, sorted score-
            descending. Empty list when the embedder is not wired, the
            query embed fails, or no skills have cached embeddings.
            Fail-open throughout — callers should treat empty as "fall
            back to priority-ordered everything."
        """
        if not task_text or not task_text.strip():
            return []
        if self._embedder is None:
            return []
        try:
            model = self._embedder._get_model()  # noqa: SLF001
        except Exception:
            return []

        try:
            query_vec = list(model.embed([task_text]))
            if not query_vec:
                return []
            qv = query_vec[0]
            # Normalize once — bge-m3 vectors are already L2-normalized,
            # but we defensive-normalize in case a different model is
            # swapped in (nomic-embed-text, multilingual-e5, etc.).
            qnorm = sum(float(x) * float(x) for x in qv) ** 0.5 or 1.0
            qv_norm = [float(x) / qnorm for x in qv]
        except Exception:
            log.debug("SkillLoader: query embed failed", exc_info=True)
            return []

        scope_set = set(scope) if scope else None
        results: list[tuple[str, float]] = []
        for skill in self._skills.values():
            if skill.status != "active":
                continue
            if scope_set is not None and skill.name not in scope_set:
                continue
            if not skill.embedding:
                continue
            # Cosine = dot product when both vectors are L2-normalized.
            # Skill vectors may have drifted from unit length if the
            # model returned unnormalized output — normalize defensively.
            sv = skill.embedding
            snorm = sum(x * x for x in sv) ** 0.5 or 1.0
            dot = sum(float(a) * float(b) for a, b in zip(qv_norm, sv, strict=False)) / snorm
            if dot < min_score:
                continue
            results.append((skill.name, dot))

        results.sort(key=lambda t: t[1], reverse=True)
        return results[:top_k]

    def get_metadata_summary(self) -> str:
        """Get a compact summary of available skills for the system prompt."""
        active = [s for s in self.skills.values() if s.status == "active"]
        active.sort(key=lambda s: s.priority, reverse=True)
        lines = []
        for skill in active:
            triggers_str = ", ".join(skill.triggers[:3]) if skill.triggers else "manual"
            extras = ""
            if skill.priority > 0:
                extras += f" [P{skill.priority}]"
            if skill.files:
                extras += f" [+{len(skill.files)} files]"
            lines.append(
                f"- **{skill.title}** ({skill.name}): {skill.description} "
                f"[triggers: {triggers_str}]{extras}"
            )
        return "\n".join(lines) if lines else "No skills loaded."

    def get_full_prompt_section(self) -> str:
        """Get full skill instructions for the system prompt (cached after first build).

        Includes the complete body of every active skill so the agent
        can actually follow the instructions, not just know names.
        Skills ordered by priority (higher first).
        """
        if self._full_prompt_cache is not None:
            return self._full_prompt_cache

        active = [
            s for s in self.skills.values()
            if s.status == "active" and s.body.strip()
        ]
        if not active:
            return "No skills loaded."

        active.sort(key=lambda s: s.priority, reverse=True)
        sections = []
        for skill in active:
            triggers_str = ", ".join(skill.triggers) if skill.triggers else "always active"
            parts = [
                f"## {skill.title} ({skill.name})",
                f"*Triggers: {triggers_str}*\n",
                skill.body.strip(),
            ]
            if skill.examples:
                parts.append("\n### Examples")
                for ex in skill.examples:
                    inp = ex.get("input", "")
                    out = ex.get("output", "")
                    parts.append(f"**Input:** {inp}\n**Output:** {out}\n")
            if skill.output_format:
                parts.append(f"\n### Expected Output Format\n{skill.output_format}")
            sections.append("\n".join(parts))
        result = "\n\n---\n\n".join(sections)
        self._full_prompt_cache = result
        return result

    def list_skills(self) -> list[dict]:
        """List all skills with metadata (for API/UI)."""
        return [
            {
                "name": s.name,
                "title": s.title,
                "description": s.description,
                "category": s.category,
                "status": s.status,
                "triggers": s.triggers,
                "path": s.path,
                "files": s.files,
                "author": s.author,
                "tools_granted": s.tools_granted,
                "depends_on": s.depends_on,
                "priority": s.priority,
                "model_tier": s.model_tier,
                "examples": s.examples,
                "output_format": s.output_format,
                "modified_at": s.modified_at,
            }
            for s in self.skills.values()
        ]

    def get_skill_file(self, skill_name: str, filename: str) -> str | None:
        """Read an additional file from a skill folder."""
        skill = self.get_skill(skill_name)
        if not skill:
            return None
        skill_dir = self._skills_dir / Path(skill.path).parent
        file_path = skill_dir / filename
        if file_path.is_file() and file_path.resolve().is_relative_to(self._skills_dir.resolve()):
            return file_path.read_text(encoding="utf-8")
        return None

    @staticmethod
    def _stat_mtime(path: Path) -> float:
        """Return mtime of `path` in epoch seconds, 0.0 on failure."""
        try:
            return path.stat().st_mtime
        except OSError:
            return 0.0

    def _load_skill(self, path: Path) -> None:
        """Load a single skill file and discover additional files."""
        raw = path.read_text(encoding="utf-8")
        # Tolerate UTF-8 BOM prefix — some editors (e.g. Windows Notepad, a few
        # PKM tools) write BOM'd files. Without this strip, frontmatter.loads
        # fails to recognise the leading `---` fence and returns empty metadata,
        # which makes the skill load under `path.stem` ("SKILL") with no title,
        # triggers, or description.
        if raw.startswith("\ufeff"):
            raw = raw.lstrip("\ufeff")
        post = frontmatter.loads(raw)
        meta = post.metadata

        # Fall back to the folder name when metadata is missing — `path.stem`
        # for SKILL.md is the literal string "SKILL", which is never what the
        # user means. Skills live at `.../skills/<category>/<slug>/SKILL.md`,
        # so `path.parent.name` is the slug the user recognises.
        default_name = path.parent.name if path.name == "SKILL.md" else path.stem
        name = meta.get("name", default_name)
        if name in self._skills:
            return

        # Discover additional files in the skill folder
        skill_dir = path.parent
        extra_files: list[str] = []
        if skill_dir.is_dir():
            for f in sorted(skill_dir.rglob("*")):
                if not f.is_file():
                    continue
                if f.name in ("SKILL.md", ".gitkeep"):
                    continue
                try:
                    rel = str(f.relative_to(skill_dir)).replace("\\", "/")
                    extra_files.append(rel)
                except ValueError:
                    pass

        # Parse priority safely
        try:
            priority = int(meta.get("priority", 0))
        except (ValueError, TypeError):
            priority = 0

        # Pull structured YAML blocks that are neither known Skill fields
        # nor prompt text. These live under the `config` dict so services
        # can read them (e.g. kb-ingest reads `language.output_policy`).
        _KNOWN_META = {
            "name", "title", "description", "triggers", "category",
            "status", "author", "tools_granted", "depends_on", "priority",
            "model_tier", "examples", "output_format",
            "version", "signature", "publisher",
        }
        config_blocks: dict = {}
        for k, v in meta.items():
            if k in _KNOWN_META:
                continue
            if isinstance(v, dict):
                config_blocks[k] = v

        # Compute content hash for tamper detection (skill body only — meta
        # is allowed to change without invalidating a signature).
        import hashlib
        content_hash = hashlib.sha256(post.content.encode("utf-8")).hexdigest()

        # Signature verification (gap #8 — signed-skill runtime)
        from agntspace.skills.signing import verify_signature
        raw_status = str(meta.get("status", "active"))
        raw_signature = str(meta.get("signature", ""))
        raw_publisher = str(meta.get("publisher", "user"))
        sig_status, sig_reason = verify_signature(
            post.content, raw_signature, publisher=raw_publisher,
        )
        effective_status = raw_status
        if sig_status in ("invalid", "untrusted_publisher"):
            # Tampered or unverifiable signed skill — quarantine to keep it
            # out of the agent's system prompt until the user rewrites,
            # re-signs, or adds the publisher to the trust store.
            effective_status = "quarantined"
            log.warning(
                "Skill '%s' has a %s signature (%s) and was quarantined",
                name, sig_status, sig_reason,
            )

        self._skills[name] = Skill(
            name=name,
            title=meta.get("title", name),
            description=meta.get("description", ""),
            triggers=meta.get("triggers", []),
            category=meta.get("category", path.parent.parent.name if path.name == "SKILL.md" else path.parent.name),
            status=effective_status,
            body=post.content,
            path=str(path.relative_to(self._skills_dir)),
            files=extra_files,
            author=meta.get("author", ""),
            tools_granted=meta.get("tools_granted", []),
            depends_on=meta.get("depends_on", []),
            priority=priority,
            model_tier=meta.get("model_tier", ""),
            examples=meta.get("examples", []),
            output_format=meta.get("output_format", ""),
            version=str(meta.get("version", "0.0.0")),
            signature=raw_signature,
            publisher=str(meta.get("publisher", "user")),
            content_hash=content_hash,
            config=config_blocks,
            modified_at=self._stat_mtime(path),
        )
