# ClawTex — Governed Agent with Genetic Memory
# Built by DeVere Cooley / AN2B LLC · an2b.com
# BB4C — Breath Before Code

"""
Skill loader — discovers and loads ClawTex skills from the skills/ directory.

Skills are OpenClaw-compatible: each skill is a directory containing a SKILL.md
file with YAML frontmatter (between --- delimiters) for metadata.

SKILL.md format (OpenClaw-compatible):
  ---
  name: skill-name
  display_name: Human Readable Name
  description: one-line description
  version: 1.0.0
  tags: [tag1, tag2]
  ---

  ## Body content...

Legacy format (no frontmatter) is also supported for backwards compatibility:
  # Skill Name
  description: one-line description
  version: 1.0.0
  tools: [tool_name_1, tool_name_2]
"""

from __future__ import annotations

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

logger = logging.getLogger(__name__)

_SKILLS_DIR = Path(os.environ.get("CLAWTEX_SKILLS_DIR", "./skills")).resolve()


@dataclass
class Skill:
    name: str
    path: Path
    description: str
    display_name: str = ""
    version: str = "1.0.0"
    tags: list[str] = field(default_factory=list)
    tools: list[str] = field(default_factory=list)
    raw_content: str = ""


def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
    """Extract YAML frontmatter between --- delimiters. Returns (metadata, body)."""
    try:
        import yaml
    except ImportError:
        return {}, content

    fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)", content, re.DOTALL)
    if fm_match:
        try:
            meta = yaml.safe_load(fm_match.group(1)) or {}
        except Exception:  # noqa: BLE001
            meta = {}
        return meta, fm_match.group(2)
    return {}, content


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

    def __init__(self, skills_dir: str | Path | None = None) -> None:
        self._dir = Path(skills_dir or _SKILLS_DIR)
        self._skills: dict[str, Skill] = {}

    def load_all(self) -> list[Skill]:
        """Scan the skills directory and load all valid skills."""
        if not self._dir.exists():
            logger.warning("Skills directory not found: %s", self._dir)
            return []

        for skill_dir in sorted(self._dir.iterdir()):
            if not skill_dir.is_dir():
                continue
            skill_md = skill_dir / "SKILL.md"
            if not skill_md.exists():
                continue
            try:
                skill = self._parse_skill(skill_dir, skill_md)
                self._skills[skill.name] = skill
                logger.info("Loaded skill: %s v%s", skill.name, skill.version)
            except Exception as exc:  # noqa: BLE001
                logger.warning("Failed to load skill from %s: %s", skill_dir, exc)

        return list(self._skills.values())

    def _parse_skill(self, skill_dir: Path, skill_md: Path) -> Skill:
        content = skill_md.read_text(encoding="utf-8")
        meta, body = _parse_frontmatter(content)

        # Prefer YAML frontmatter fields; fall back to regex/heading extraction
        name: str = str(meta.get("name") or skill_dir.name)
        display_name: str = str(meta.get("display_name") or meta.get("displayName") or "")
        description: str = str(meta.get("description") or "")
        version: str = str(meta.get("version") or "1.0.0")

        tags_raw = meta.get("tags") or []
        tags: list[str] = [str(t) for t in tags_raw] if isinstance(tags_raw, list) else []

        # Fallback: parse legacy format (no frontmatter)
        if not description:
            desc_match = re.search(r"^description:\s*(.+)$", body, re.MULTILINE | re.IGNORECASE)
            if desc_match:
                description = desc_match.group(1).strip()
            else:
                lines = [ln.strip() for ln in body.split("\n") if ln.strip() and not ln.startswith("#")]
                description = lines[0] if lines else "No description."

        # Fallback version from body when frontmatter had none
        if not meta.get("version"):
            ver_match = re.search(r"^version:\s*(.+)$", body, re.MULTILINE | re.IGNORECASE)
            if ver_match:
                version = ver_match.group(1).strip()

        # Tools list — frontmatter takes priority, then legacy inline format
        tools: list[str] = []
        if meta.get("tools"):
            tools_raw = meta["tools"]
            tools = [str(t) for t in tools_raw] if isinstance(tools_raw, list) else []
        else:
            tools_match = re.search(r"^tools:\s*\[(.+)\]$", body, re.MULTILINE | re.IGNORECASE)
            if tools_match:
                tools = [t.strip() for t in tools_match.group(1).split(",") if t.strip()]

        return Skill(
            name=name,
            path=skill_dir,
            description=description,
            display_name=display_name,
            version=version,
            tags=tags,
            tools=tools,
            raw_content=content,
        )

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def get(self, name: str) -> Skill | None:
        """Return the Skill object for the named skill, or None."""
        return self._skills.get(name)

    def get_skill(self, name: str) -> str | None:
        """Return the full SKILL.md content for the named skill, or None."""
        skill = self._skills.get(name)
        return skill.raw_content if skill else None

    def list_skills(self) -> list[dict[str, str]]:
        """Return a list of {name, description} dicts for all loaded skills."""
        return [{"name": s.name, "description": s.description} for s in self._skills.values()]

    def match_skill(self, user_message: str) -> str | None:
        """
        Return the best matching skill name for the user message, or None.

        Simple keyword overlap: scans description + tags + name for words
        that appear in the user message. Returns the skill with the most
        keyword matches (minimum 1 match required).
        """
        if not self._skills:
            return None

        words = set(re.findall(r"\w+", user_message.lower()))
        if not words:
            return None

        best_name: str | None = None
        best_score = 0

        for skill in self._skills.values():
            # Build searchable text from description + tags + name
            searchable = " ".join([
                skill.description.lower(),
                " ".join(skill.tags).lower(),
                skill.name.lower().replace("-", " ").replace("_", " "),
                skill.display_name.lower(),
            ])
            skill_words = set(re.findall(r"\w+", searchable))
            score = len(words & skill_words)
            if score > best_score:
                best_score = score
                best_name = skill.name

        return best_name if best_score > 0 else None

    def all(self) -> list[Skill]:
        return list(self._skills.values())

    def to_context_block(self) -> str:
        """Return an <available_skills> block for system prompt injection."""
        if not self._skills:
            return ""
        lines = [f"{s.name} — {s.description}" for s in self._skills.values()]
        return "<available_skills>\n" + "\n".join(lines) + "\n</available_skills>"
