# tools/skills_tools.py — Procedural memory: agent-managed skills (reusable workflows).
# Skills live in ~/.mugi/workspace/skills/<name>/SKILL.md with optional supporting files.
# Anti-hallucination: fuzzy name resolution, actionable errors, path containment.
from __future__ import annotations

import os
import re
import shutil
from datetime import datetime, timezone

from langchain_core.tools import tool
from pydantic import BaseModel, Field
from tools._logging import _debug_log, _emit_input_status
from utils.path_safety import safe_resolve_path, secure_write_file
from utils.skill_usage import record_skill_event

_DEFAULT_WORKSPACE = os.path.expanduser("~/.mugi/workspace")

_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
_YAML_KV_RE = re.compile(r"^(\w[\w\s]*):\s*(.+)$", re.MULTILINE)

_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def _get_skills_base() -> str:
    from core import get_workspace_path
    workspace = get_workspace_path() or _DEFAULT_WORKSPACE
    base = os.path.join(workspace, "skills")
    os.makedirs(base, exist_ok=True)
    return base


def _get_bundled_skills_base() -> str | None:
    """Return the bundled skills directory path, or None if it doesn't exist."""
    try:
        from core.config import get_config
        cfg = get_config()
        custom = (cfg.get("skills") or {}).get("bundled_path", "").strip()
        if custom and os.path.isdir(custom):
            return custom
    except Exception:
        pass
    default = os.path.join(_PROJECT_ROOT, "bundled_skills")
    if os.path.isdir(default):
        return default
    return None


def _is_bundled_skill(name: str) -> bool:
    """Return True if a skill name exists in the bundled (read-only) layer."""
    bundled = _get_bundled_skills_base()
    if not bundled:
        return False
    skill_path = os.path.join(bundled, name, "SKILL.md")
    return os.path.isfile(skill_path)


def _is_bundled_abs_path(path: str) -> bool:
    bundled = _get_bundled_skills_base()
    if not bundled:
        return False
    try:
        p = os.path.abspath(path)
        b = os.path.abspath(bundled)
        return os.path.commonpath([p, b]) == b
    except Exception:
        return False


def _record_skill_event_safe(skill_name: str, event: str, **kwargs) -> None:
    try:
        record_skill_event(
            skills_base=_get_skills_base(),
            skill_name=skill_name,
            event=event,
            **kwargs,
        )
    except Exception:
        _debug_log(f"[skills_usage] failed to record {event} for '{skill_name}'", "DEBUG")


def _list_skill_dirs() -> list[str]:
    """Return sorted list of skill directory names that contain a SKILL.md.

    Merges user skills with bundled skills.  User skills override bundled ones
    with the same name (user workspace takes priority).
    """
    names: set[str] = set()

    # Bundled (read-only) skills
    bundled = _get_bundled_skills_base()
    if bundled:
        try:
            for e in os.listdir(bundled):
                if os.path.isfile(os.path.join(bundled, e, "SKILL.md")):
                    names.add(e)
        except OSError:
            pass

    # User workspace skills (override bundled)
    base = _get_skills_base()
    try:
        entries = os.listdir(base)
    except OSError:
        entries = []
    for e in entries:
        if os.path.isdir(os.path.join(base, e)) and os.path.isfile(os.path.join(base, e, "SKILL.md")):
            names.add(e)

    return sorted(names)


def _normalize_name(raw: str) -> str:
    """Normalize a raw skill name: lowercase, strip, spaces/underscores to hyphens, collapse."""
    n = (raw or "").strip().strip('"\'').lower()
    n = re.sub(r"[\s_]+", "-", n)
    n = re.sub(r"-+", "-", n).strip("-")
    return n


def _resolve_skill_name(raw_name: str) -> tuple[str | None, str]:
    """Resolve a possibly-hallucinated skill name to an existing directory name.

    Returns (resolved_name, error_message). If resolved_name is not None, error is empty.
    On failure, error contains the full list of available skills for self-correction.
    """
    available = _list_skill_dirs()
    if not available:
        return None, "No skills exist yet. Use skill_manage(action='create', ...) to create one."

    normalized = _normalize_name(raw_name)
    if not normalized:
        return None, f"Empty skill name. Available skills: {', '.join(available)}. Use the exact name from this list."

    # Exact match
    if normalized in available:
        return normalized, ""

    # Case-insensitive match against available (already lowercase in normalized, but dir names may vary)
    lower_map = {d.lower(): d for d in available}
    if normalized in lower_map:
        return lower_map[normalized], ""

    # Fuzzy: check if normalized is a unique substring of exactly one skill
    substring_matches = [d for d in available if normalized in d.lower()]
    if len(substring_matches) == 1:
        return substring_matches[0], ""

    # Fuzzy: check if any skill is a substring of the input (user typed more than needed)
    reverse_matches = [d for d in available if d.lower() in normalized]
    if len(reverse_matches) == 1:
        return reverse_matches[0], ""

    return None, f"Skill '{raw_name}' not found. Available skills: {', '.join(available)}. Use the exact name from this list."


def _parse_frontmatter(content: str) -> dict[str, str]:
    """Extract YAML frontmatter key-value pairs from SKILL.md content."""
    m = _FRONTMATTER_RE.match(content)
    if not m:
        return {}
    fm_block = m.group(1)
    result: dict[str, str] = {}
    for kv in _YAML_KV_RE.finditer(fm_block):
        key = kv.group(1).strip().lower()
        val = kv.group(2).strip().strip('"\'')
        result[key] = val
    return result


def _safe_skill_path(skill_name: str, subpath: str = "") -> tuple[str | None, str]:
    """Resolve a path within a skill directory, jailed to skills base.

    Checks user workspace first, then bundled skills.
    Returns (abs_path, error). Blocks traversal like ../../MEMORY.
    """
    # Try user workspace first
    base = _get_skills_base()
    rel = os.path.join(skill_name, subpath) if subpath else skill_name
    user_path, user_err = safe_resolve_path(base, rel, allow_write=True, create_parents=True)
    if user_path and os.path.exists(user_path):
        return user_path, ""

    # Fall back to bundled skills (read-only)
    bundled = _get_bundled_skills_base()
    if bundled:
        bundled_full = os.path.normpath(os.path.join(bundled, rel))
        if bundled_full.startswith(os.path.normpath(bundled)) and os.path.exists(bundled_full):
            return bundled_full, ""

    # For create/write operations, return the user workspace path
    return user_path, user_err


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------

@tool
def skills_list(category: str = "") -> str:
    """List all available skills (name + description). Returns metadata only — cheap tokens.
    Use this to discover what skills exist before loading one with skill_view.
    Optional category filter (skills in subdirectories act as categories)."""
    _debug_log("[TOOL] skills_list called", "TOOL")
    _emit_input_status("Listing skills…")
    available = _list_skill_dirs()
    if not available:
        _emit_input_status("")
        return "No skills exist yet. After completing a complex task, use skill_manage(action='create') to save the workflow."

    entries: list[str] = []
    base = _get_skills_base()
    bundled = _get_bundled_skills_base()
    category_norm = _normalize_name(category) if category else ""

    for name in available:
        if category_norm and not name.startswith(category_norm):
            continue
        # Check user workspace first, then bundled
        skill_file = os.path.join(base, name, "SKILL.md")
        source = ""
        if not os.path.isfile(skill_file) and bundled:
            skill_file = os.path.join(bundled, name, "SKILL.md")
            source = " [bundled]"
        try:
            with open(skill_file, "r", encoding="utf-8") as f:
                content = f.read(2000)
        except OSError:
            continue
        fm = _parse_frontmatter(content)
        desc = fm.get("description", "(no description)")
        version = fm.get("version", "")
        ver_str = f" v{version}" if version else ""
        entries.append(f"- **{name}**{ver_str}{source}: {desc}")

    _emit_input_status("")
    if not entries:
        return f"No skills found matching category '{category}'." if category else "No skills exist yet."
    header = f"Available skills ({len(entries)}):"
    return header + "\n" + "\n".join(entries)


class SkillViewInput(BaseModel):
    skill_name: str = Field(description="Name of the skill to view (use skills_list to find exact names).")
    file: str = Field(default="", description="Optional: load a specific supporting file instead of SKILL.md (e.g. 'references/setup.md').")


@tool(args_schema=SkillViewInput)
def skill_view(skill_name: str, file: str = "") -> str:
    """Load full content of a skill's SKILL.md (or a specific supporting file).
    Use skills_list first to find the exact skill name."""
    _debug_log(f"[TOOL] skill_view called: {skill_name}", "TOOL")
    _emit_input_status("Loading skill…")

    resolved, err = _resolve_skill_name(skill_name)
    if err:
        _emit_input_status("")
        return err

    target = file.strip() if file else "SKILL.md"
    abs_path, path_err = _safe_skill_path(resolved, target)
    if path_err:
        _emit_input_status("")
        return f"Cannot access '{target}' in skill '{resolved}': {path_err}"

    if not os.path.isfile(abs_path):
        skill_dir = os.path.join(_get_skills_base(), resolved)
        try:
            all_files = []
            for root, dirs, files in os.walk(skill_dir):
                for f in files:
                    rel = os.path.relpath(os.path.join(root, f), skill_dir)
                    all_files.append(rel)
        except OSError:
            all_files = []
        hint = f" Files in this skill: {', '.join(all_files)}" if all_files else ""
        _emit_input_status("")
        return f"File '{target}' not found in skill '{resolved}'.{hint}"

    try:
        with open(abs_path, "r", encoding="utf-8") as f:
            content = f.read()
    except OSError as e:
        _emit_input_status("")
        return f"Error reading '{target}': {e}"

    _emit_input_status("")
    if not _is_bundled_abs_path(abs_path):
        _record_skill_event_safe(resolved, "view")
    return f"─── {resolved}/{target} ───\n{content}"


class SkillManageInput(BaseModel):
    action: str = Field(description="One of: create, edit, patch, archive, delete, write_file, remove_file.")
    skill_name: str = Field(description="Skill name (lowercase-hyphenated, e.g. 'docker-setup').")
    content: str = Field(default="", description="Full SKILL.md content (for create/edit) or file content (for write_file).")
    find: str = Field(default="", description="Text to find (for patch action only).")
    replace: str = Field(default="", description="Replacement text (for patch action only).")
    file: str = Field(default="", description="Relative file path within skill dir (for write_file/remove_file).")


@tool(args_schema=SkillManageInput)
def skill_manage(
    action: str,
    skill_name: str,
    content: str = "",
    find: str = "",
    replace: str = "",
    file: str = "",
) -> str:
    """Create, edit, patch, archive, or delete skills. Skills are reusable workflows saved as SKILL.md files.
    Actions: create (new skill), edit (full rewrite), patch (find-and-replace),
    archive (move to skills/.archive), delete (remove skill), write_file (add supporting file),
    remove_file (remove supporting file).
    Use YAML frontmatter: ---\\nname: ...\\ndescription: ...\\nversion: 1.0.0\\n---"""
    _debug_log(f"[TOOL] skill_manage called: action={action}, skill={skill_name}", "TOOL")
    _emit_input_status("Managing skill…")

    action = (action or "").strip().lower()
    valid_actions = ("create", "edit", "patch", "archive", "delete", "write_file", "remove_file")
    if action not in valid_actions:
        _emit_input_status("")
        if action == "list":
            return "Invalid action 'list'. Use skills_list() to list skills, or skill_manage() actions: create, edit, patch, archive, delete, write_file, remove_file."
        return f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}."

    norm_name = _normalize_name(skill_name)
    if not norm_name:
        _emit_input_status("")
        return "Skill name cannot be empty."

    if action != "create" and _is_bundled_skill(norm_name):
        _emit_input_status("")
        return (
            f"Skill '{norm_name}' is a bundled (read-only) skill and cannot be modified. "
            "To customize it, create a user skill with the same name using action='create'."
        )

    base = _get_skills_base()

    if action == "create":
        skill_dir_path, dir_err = _safe_skill_path(norm_name)
        if dir_err:
            _emit_input_status("")
            return f"Cannot create skill directory: {dir_err}"
        skill_md_path = os.path.join(skill_dir_path, "SKILL.md")
        if os.path.exists(skill_md_path):
            _emit_input_status("")
            return f"Skill '{norm_name}' already exists. Use action='edit' to rewrite or action='patch' to update."
        if not content.strip():
            _emit_input_status("")
            return "Content is required for create. Provide full SKILL.md content with YAML frontmatter."
        os.makedirs(skill_dir_path, exist_ok=True)
        try:
            secure_write_file(skill_md_path, content, mode=0o644)
        except Exception as e:
            _emit_input_status("")
            return f"Failed to write SKILL.md: {e}"
        _record_skill_event_safe(norm_name, "patch")
        _emit_input_status("")
        return f"Created skill '{norm_name}' ({len(content)} chars)."

    # All other actions require the skill to exist
    resolved, err = _resolve_skill_name(skill_name)
    if err:
        if action in {"delete", "archive"}:
            _emit_input_status("")
            return err
        _emit_input_status("")
        return err

    skill_dir = os.path.join(base, resolved)
    skill_md = os.path.join(skill_dir, "SKILL.md")

    if action == "edit":
        if not content.strip():
            _emit_input_status("")
            return "Content is required for edit. Provide the full new SKILL.md content."
        try:
            secure_write_file(skill_md, content, mode=0o644)
        except Exception as e:
            _emit_input_status("")
            return f"Failed to write SKILL.md: {e}"
        _record_skill_event_safe(resolved, "patch")
        _emit_input_status("")
        return f"Updated skill '{resolved}' ({len(content)} chars)."

    elif action == "patch":
        if not find:
            _emit_input_status("")
            return "The 'find' parameter is required for patch. Provide the text to find in SKILL.md."
        try:
            with open(skill_md, "r", encoding="utf-8") as f:
                existing = f.read()
        except OSError as e:
            _emit_input_status("")
            return f"Failed to read SKILL.md: {e}"
        if find not in existing:
            _emit_input_status("")
            return f"Text to find not found in '{resolved}/SKILL.md'. Check exact whitespace and content."
        patched = existing.replace(find, replace, 1)
        try:
            secure_write_file(skill_md, patched, mode=0o644)
        except Exception as e:
            _emit_input_status("")
            return f"Failed to write patched SKILL.md: {e}"
        _record_skill_event_safe(resolved, "patch")
        _emit_input_status("")
        return f"Patched skill '{resolved}': replaced {len(find)} chars with {len(replace)} chars."

    elif action == "delete":
        try:
            shutil.rmtree(skill_dir)
        except OSError as e:
            _emit_input_status("")
            return f"Failed to delete skill '{resolved}': {e}"
        _record_skill_event_safe(resolved, "patch")
        _emit_input_status("")
        return f"Deleted skill '{resolved}' and all its files."

    elif action == "archive":
        archive_root = os.path.join(base, ".archive")
        os.makedirs(archive_root, exist_ok=True)
        dest_dir = os.path.join(archive_root, resolved)
        if os.path.exists(dest_dir):
            suffix = datetime.now().strftime("%Y%m%dT%H%M%S")
            dest_dir = os.path.join(archive_root, f"{resolved}-{suffix}")
        try:
            os.replace(skill_dir, dest_dir)
        except OSError as e:
            _emit_input_status("")
            return f"Failed to archive skill '{resolved}': {e}"

        rejected_pending = 0
        try:
            from evolution.pending_store import reject_pending_for_skill

            rejected_pending = int(
                reject_pending_for_skill(
                    resolved,
                    reason="Archived workflow; pending proposal no longer applies.",
                )
                or 0
            )
        except Exception:
            rejected_pending = 0

        scheduled_updated = 0
        try:
            from chat_storage import rewrite_scheduled_job_prompts

            _rw = rewrite_scheduled_job_prompts({resolved: None}, active_only=True)
            scheduled_updated = int((_rw or {}).get("updated") or 0)
        except Exception:
            scheduled_updated = 0

        _record_skill_event_safe(
            resolved,
            "patch",
            state="archived",
            archived_at=datetime.now(timezone.utc).isoformat(),
        )
        _emit_input_status("")
        return (
            f"Archived skill '{resolved}' to '.archive'. "
            f"Pending proposals rejected: {rejected_pending}. "
            f"Scheduled prompts updated: {scheduled_updated}."
        )

    elif action == "write_file":
        if not file.strip():
            _emit_input_status("")
            return "The 'file' parameter is required for write_file (e.g. 'references/setup.md')."
        abs_path, path_err = _safe_skill_path(resolved, file.strip())
        if path_err:
            _emit_input_status("")
            return f"Cannot write to '{file}': {path_err}"
        try:
            secure_write_file(abs_path, content or "", mode=0o644)
        except Exception as e:
            _emit_input_status("")
            return f"Failed to write '{file}': {e}"
        _record_skill_event_safe(resolved, "patch")
        _emit_input_status("")
        return f"Wrote '{file}' in skill '{resolved}' ({len(content or '')} chars)."

    elif action == "remove_file":
        if not file.strip():
            _emit_input_status("")
            return "The 'file' parameter is required for remove_file."
        abs_path, path_err = _safe_skill_path(resolved, file.strip())
        if path_err:
            _emit_input_status("")
            return f"Cannot access '{file}': {path_err}"
        if not os.path.isfile(abs_path):
            _emit_input_status("")
            return f"File '{file}' not found in skill '{resolved}'."
        try:
            os.remove(abs_path)
        except OSError as e:
            _emit_input_status("")
            return f"Failed to remove '{file}': {e}"
        _record_skill_event_safe(resolved, "patch")
        _emit_input_status("")
        return f"Removed '{file}' from skill '{resolved}'."

    _emit_input_status("")
    return f"Unknown action '{action}'."


# ---------------------------------------------------------------------------
# Helpers for system prompt integration
# ---------------------------------------------------------------------------

def build_skills_index() -> str:
    """Build a compact skills index for injection into the system prompt.

    Returns empty string if no skills exist. Each skill is one line: name — description.
    Includes both user and bundled skills.
    """
    available = _list_skill_dirs()
    if not available:
        return ""
    base = _get_skills_base()
    bundled = _get_bundled_skills_base()
    lines: list[str] = []
    for name in available:
        skill_file = os.path.join(base, name, "SKILL.md")
        if not os.path.isfile(skill_file) and bundled:
            skill_file = os.path.join(bundled, name, "SKILL.md")
        try:
            with open(skill_file, "r", encoding="utf-8") as f:
                content = f.read(2000)
        except OSError:
            continue
        fm = _parse_frontmatter(content)
        desc = fm.get("description", "(no description)")
        lines.append(f"  - {name}: {desc}")
    if not lines:
        return ""
    return "Your saved skills:\n" + "\n".join(lines)
