# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Skills System

Skills are plugins that extend the agent's capabilities.
Each skill is a directory containing:
  - SKILL.md - Description and usage instructions (included in system prompt)
  - skill.py - Python code defining tools (optional)
  - config.yaml - Skill configuration (optional)

Skills can:
  - Add new tools
  - Provide domain knowledge via SKILL.md
  - Define scheduled tasks
"""

import importlib.util
import logging
import os
import sys
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import yaml

from .config import SKILLS_DIR
from .tools import Tool, get_tool_registry

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Skill prerequisite definitions
# ---------------------------------------------------------------------------

# Maps skill name → list of (check_fn, friendly_label) tuples.
# Every check_fn() must return True for the skill to register its tools.
# Checks that fail emit a WARNING at load time so the log explains
# why a skill's tools are missing without crashing the agent.
#
# Adding a new check: append to the list for the relevant skill, or create
# a new entry. check_fn receives no arguments and returns bool.


def _env(key: str):
    """Return a check function that tests whether an env var is set."""
    return lambda: bool(os.environ.get(key, "").strip())


def _importable(module: str):
    """Return a check function that tests whether a Python module is importable."""
    import importlib

    def _check():
        try:
            return importlib.util.find_spec(module) is not None
        except (ModuleNotFoundError, ValueError):
            return False

    return _check


def _file_exists(path_str: str):
    """Return a check function that tests whether a file exists on disk."""
    return lambda: Path(path_str).expanduser().exists()


def _playwright_browsers_installed():
    """Check that playwright has at least one browser binary downloaded."""
    cache = Path.home() / ".cache" / "ms-playwright"
    if not cache.exists():
        return False
    # Look for any chromium-* directory with actual binaries
    return any(d.is_dir() and d.name.startswith("chromium") for d in cache.iterdir())


def _any_tts_engine():
    """Check that at least one TTS engine is available."""
    for mod in ("pyttsx3", "gtts", "edge_tts"):
        try:
            import importlib

            if importlib.util.find_spec(mod) is not None:
                return True
        except (ModuleNotFoundError, ValueError):
            continue
    return False


def _gmail_token() -> bool:
    """True if Gmail OAuth token exists (email/triage can use Gmail API path)."""
    p = Path.home() / ".familiar" / "data" / "google_token_gmail.json"
    return p.exists()


def _either(check_a, check_b) -> bool:
    """True if either condition is met — used for Gmail API OR IMAP/SMTP."""
    return check_a() or check_b()


# Skills with no external deps (calendar uses local JSON, tasks uses local JSON, etc.)
# are NOT listed here — absence from the dict means "always register".
SKILL_PREREQUISITES: dict[str, list[tuple]] = {
    "email": [
        # Either Gmail OAuth token exists OR IMAP/SMTP credentials are set.
        (
            lambda: (
                _gmail_token()
                or (
                    bool(os.environ.get("EMAIL_ADDRESS")) and bool(os.environ.get("EMAIL_PASSWORD"))
                )
            ),
            "Email not configured: run /connect google auth gmail  OR  set EMAIL_ADDRESS + EMAIL_PASSWORD",
        ),
    ],
    "calendar": [
        (
            lambda: (
                (
                    _importable("google.oauth2")()
                    and _importable("googleapiclient")()
                    and _importable("httplib2")()
                )
                or (_importable("caldav")() and _importable("vobject")())
            ),
            "Calendar requires Google libs or CalDAV libs (auto-installs on first use)",
        ),
    ],
    "gdrive": [
        (
            _importable("google.oauth2"),
            "google-auth not installed (auto-installs on first use)",
        ),
        (_importable("googleapiclient"), "google-api-python-client not installed"),
        (_importable("httplib2"), "httplib2 not installed (auto-installs on first use)"),
    ],
    "sms": [
        (_env("TWILIO_ACCOUNT_SID"), "TWILIO_ACCOUNT_SID env var not set"),
        (_env("TWILIO_AUTH_TOKEN"), "TWILIO_AUTH_TOKEN env var not set"),
        (_env("TWILIO_PHONE_NUMBER"), "TWILIO_PHONE_NUMBER env var not set"),
        (_importable("twilio"), "twilio not installed (auto-installs on first use)"),
    ],
    "voice": [
        (
            _importable("speech_recognition"),
            "SpeechRecognition not installed (auto-installs on first use)",
        ),
        (
            _any_tts_engine,
            "No TTS engine installed (auto-installs on first use)",
        ),
    ],
    "transcription": [
        (_importable("whisper"), "whisper not installed (auto-installs on first use)"),
    ],
    "gpio": [
        (
            _importable("RPi.GPIO"),
            "RPi.GPIO not installed — GPIO skill requires Raspberry Pi hardware",
        ),
    ],
    # ── Email-dependent skills ─────────────────────────────────────────────────
    # meetings, triage, and notifications all read EMAIL_ADDRESS / EMAIL_PASSWORD
    # for IMAP/SMTP access. Without them the tools fail at call time.
    "meetings": [
        # Gmail API token (for invite sending) OR SMTP credentials
        (
            lambda: (
                _gmail_token()
                or (
                    bool(os.environ.get("EMAIL_ADDRESS")) and bool(os.environ.get("EMAIL_PASSWORD"))
                )
            ),
            "Meetings not configured: run /connect google auth gmail  OR  set EMAIL_ADDRESS + EMAIL_PASSWORD",
        ),
    ],
    "triage": [
        # Gmail API token OR IMAP credentials
        (
            lambda: (
                _gmail_token()
                or (
                    bool(os.environ.get("EMAIL_ADDRESS")) and bool(os.environ.get("EMAIL_PASSWORD"))
                )
            ),
            "Triage not configured: run /connect google auth gmail  OR  set EMAIL_ADDRESS + EMAIL_PASSWORD",
        ),
    ],
    "notifications": [
        # At least one notification channel must be configured.
        # We check for email channel (most common); ntfy and Telegram are optional extras.
        # Since any channel works, we use a soft check: warn but still register
        # (notification tools gracefully skip unconfigured channels at runtime).
    ],
    "messaging": [
        # messaging detects available platforms at runtime (iMessage/WhatsApp/Signal/SMS)
        # so no hard prerequisite — detection is built into detect_available_platforms().
        # Soft note: warn if NO platform will be available.
    ],
    "triggers": [
        # Webhook secret is optional (triggers work without HMAC verification).
        # No hard prerequisite — webhook server starts on any port.
    ],
    "nextcloud": [
        (_env("NEXTCLOUD_URL"), "NEXTCLOUD_URL env var not set"),
        (_env("NEXTCLOUD_USER"), "NEXTCLOUD_USER env var not set"),
        (_env("NEXTCLOUD_TOKEN"), "NEXTCLOUD_TOKEN env var not set"),
        (_importable("caldav"), "caldav not installed (auto-installs on first use)"),
        (_importable("vobject"), "vobject not installed (auto-installs on first use)"),
    ],
    "browser": [
        (
            _importable("playwright.sync_api"),
            "playwright not installed (auto-installs on first use)",
        ),
        (
            _playwright_browsers_installed,
            "Playwright browsers not downloaded (run: playwright install chromium)",
        ),
    ],
    "proton": [
        # No hard prerequisites — tools handle missing Bridge/VPN gracefully
        # with helpful setup messages.
    ],
    # ── Self-hosted integrations ──────────────────────────────────────────────
    "homeassistant": [
        (_env("HOMEASSISTANT_URL"), "HOMEASSISTANT_URL env var not set"),
        (_env("HOMEASSISTANT_TOKEN"), "HOMEASSISTANT_TOKEN env var not set"),
    ],
    "vaultwarden": [
        (_env("VAULTWARDEN_URL"), "VAULTWARDEN_URL env var not set"),
        (_env("VAULTWARDEN_TOKEN"), "VAULTWARDEN_TOKEN env var not set"),
    ],
    "joplin": [
        (_env("JOPLIN_TOKEN"), "JOPLIN_TOKEN env var not set"),
    ],
    "jellyfin": [
        (_env("JELLYFIN_URL"), "JELLYFIN_URL env var not set"),
        (_env("JELLYFIN_TOKEN"), "JELLYFIN_TOKEN env var not set"),
    ],
    "gitea": [
        (_env("GITEA_URL"), "GITEA_URL env var not set"),
        (_env("GITEA_TOKEN"), "GITEA_TOKEN env var not set"),
    ],
    "pihole": [
        (_env("PIHOLE_URL"), "PIHOLE_URL env var not set"),
    ],
}


def check_skill_prerequisites() -> dict[str, list[str]]:
    """Check all skill prerequisites and return a dict of {skill_name: [failures]}.

    Skills with no failures are omitted. Empty dict means everything is ready.
    Called at startup to give operators a clear picture of what works.
    """
    results = {}
    for skill_name, checks in SKILL_PREREQUISITES.items():
        failures = []
        for check_fn, label in checks:
            try:
                ok = check_fn()
            except Exception:
                ok = False
            if not ok:
                failures.append(label)
        if failures:
            results[skill_name] = failures
    return results


@dataclass
class Skill:
    """Represents a loaded skill."""

    name: str
    path: Path
    description: str  # Full SKILL.md content
    summary: str  # First 3 lines (title + blank + one-liner)
    tools: list[Tool]
    config: dict
    enabled: bool = True


class SkillLoader:
    """
    Loads and manages skills.

    Usage:
        loader = SkillLoader(["/path/to/skills"])
        loader.load_all()

        # Get skill docs for system prompt
        docs = loader.get_skill_docs()
    """

    def __init__(self, skill_dirs: Optional[list[str]] = None):
        self.skill_dirs = [Path(d) for d in (skill_dirs or [str(SKILLS_DIR)])]
        self.skills: dict[str, Skill] = {}
        self.tool_registry = get_tool_registry()
        self._docs_cache: dict = {}
        self._docs_lock = threading.Lock()

    def discover_skills(self) -> list[Path]:
        """Find all skill directories."""
        skill_paths = []

        for skill_dir in self.skill_dirs:
            if not skill_dir.exists():
                continue

            for item in skill_dir.iterdir():
                if item.is_dir() and not item.name.startswith("_"):
                    # Check if it has SKILL.md or skill.py
                    if (item / "SKILL.md").exists() or (item / "skill.py").exists():
                        skill_paths.append(item)

        return skill_paths

    def load_skill(self, path: Path) -> Optional[Skill]:
        """Load a single skill from a directory."""
        name = path.name

        try:
            # Read description from SKILL.md
            description = ""
            summary = ""
            skill_md = path / "SKILL.md"
            if skill_md.exists():
                description = skill_md.read_text()
                # Extract summary: title line + blank + one-line description
                lines = description.split("\n", 3)
                summary = "\n".join(lines[:3]).strip() if len(lines) >= 3 else description.strip()

            # Read config
            config = {}
            config_file = path / "config.yaml"
            if config_file.exists():
                with open(config_file) as f:
                    config = yaml.safe_load(f) or {}

            # Check if enabled
            enabled = config.get("enabled", True)

            # Load tools from skill.py
            tools = []
            skill_py = path / "skill.py"
            if skill_py.exists():
                tools = self._load_skill_module(skill_py, name)

            skill = Skill(
                name=name,
                path=path,
                description=description,
                summary=summary,
                tools=tools,
                config=config,
                enabled=enabled,
            )

            # Register tools
            if enabled:
                # Run prerequisite checks — skip registration (not the whole skill)
                # if external services are unavailable, so the skill still appears
                # in skill_docs but its tools don't inflate the LLM context.
                prereqs = SKILL_PREREQUISITES.get(name, [])
                prereq_failures = []
                for check_fn, label in prereqs:
                    try:
                        ok = check_fn()
                    except Exception:
                        ok = False
                    if not ok:
                        prereq_failures.append(label)

                if prereq_failures:
                    logger.warning(
                        f"Skill '{name}' prerequisites not met — tools will NOT be registered:\n"
                        + "\n".join(f"  • {f}" for f in prereq_failures)
                    )
                else:
                    for tool in tools:
                        self.tool_registry.register(tool)

            logger.info(f"Loaded skill: {name} ({len(tools)} tools)")
            return skill

        except Exception as e:
            logger.error(f"Failed to load skill {name}: {e}")
            return None

    def _load_skill_module(self, path: Path, skill_name: str) -> list[Tool]:
        """Load tools from a skill's Python module."""
        tools = []

        try:
            # Load module
            spec = importlib.util.spec_from_file_location(f"skill_{skill_name}", path)
            module = importlib.util.module_from_spec(spec)
            sys.modules[spec.name] = module
            spec.loader.exec_module(module)

            # Look for tools
            if hasattr(module, "TOOLS"):
                # List of Tool objects or dicts
                for t in module.TOOLS:
                    if isinstance(t, Tool):
                        tools.append(t)
                    elif isinstance(t, dict):
                        # Convert dict to Tool
                        handler = t.get("handler")
                        if handler and callable(handler):
                            tools.append(
                                Tool(
                                    name=t["name"],
                                    description=t["description"],
                                    input_schema=t["input_schema"],
                                    handler=handler,
                                    category=t.get("category", skill_name),
                                    requires_confirmation=t.get("requires_confirmation", False),
                                    input_examples=t.get("input_examples"),
                                )
                            )

            # Look for register_tools function
            if hasattr(module, "register_tools"):
                additional = module.register_tools()
                if additional:
                    tools.extend(additional)

        except Exception as e:
            logger.error(f"Error loading skill module {path}: {e}")

        return tools

    def load_all(self):
        """Load all discovered skills."""
        for path in self.discover_skills():
            skill = self.load_skill(path)
            if skill:
                self.skills[skill.name] = skill

        logger.info(f"Loaded {len(self.skills)} skills")

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

    def get_skill_summaries(self, skill_names: Optional[set] = None) -> str:
        """Get compact skill summaries for system prompt (progressive disclosure).

        Returns one-line summaries instead of full SKILL.md content.
        The LLM can call the get_skill_docs tool for full details when needed.
        """
        enabled = frozenset(s.name for s in self.skills.values() if s.enabled)
        requested = frozenset(skill_names) if skill_names is not None else None
        key = ("summaries", requested, enabled)

        with self._docs_lock:
            cached = self._docs_cache.get(key)
            if cached is not None:
                return cached

        lines = []
        for skill in self.skills.values():
            if skill.enabled and skill.summary:
                if skill_names is not None and skill.name not in skill_names:
                    continue
                # Extract the one-liner (third line of the summary, after title + blank)
                summary_lines = skill.summary.split("\n")
                one_liner = summary_lines[-1].strip() if summary_lines else skill.name
                lines.append(f"- **{skill.name}**: {one_liner}")

        result = "\n".join(lines)
        with self._docs_lock:
            self._docs_cache[key] = result
        return result

    def get_skill_detail(self, name: str) -> Optional[str]:
        """Get full SKILL.md content for a single skill (on-demand)."""
        skill = self.skills.get(name)
        if skill and skill.enabled and skill.description:
            return f"## Skill: {name}\n\n{skill.description}"
        return None

    def get_skill_docs(self, skill_names: Optional[set] = None) -> str:
        """Get skill documentation for system prompt.

        If skill_names is provided, only include docs for those skills.
        """
        enabled = frozenset(s.name for s in self.skills.values() if s.enabled)
        requested = frozenset(skill_names) if skill_names is not None else None
        key = (requested, enabled)

        with self._docs_lock:
            cached = self._docs_cache.get(key)
            if cached is not None:
                return cached

        docs = []

        for skill in self.skills.values():
            if skill.enabled and skill.description:
                if skill_names is not None and skill.name not in skill_names:
                    continue
                docs.append(f"## Skill: {skill.name}\n\n{skill.description}")

        result = "\n\n---\n\n".join(docs)
        with self._docs_lock:
            self._docs_cache[key] = result
        return result

    def enable_skill(self, name: str) -> bool:
        """Enable a skill."""
        skill = self.skills.get(name)
        if skill:
            skill.enabled = True
            with self._docs_lock:
                self._docs_cache.clear()
            for tool in skill.tools:
                self.tool_registry.register(tool)
            return True
        return False

    def disable_skill(self, name: str) -> bool:
        """Disable a skill."""
        skill = self.skills.get(name)
        if skill:
            skill.enabled = False
            with self._docs_lock:
                self._docs_cache.clear()
            for tool in skill.tools:
                self.tool_registry.unregister(tool.name)
            return True
        return False

    def list_skills(self) -> list[dict]:
        """List all skills with their status."""
        return [
            {
                "name": s.name,
                "enabled": s.enabled,
                "tools": [t.name for t in s.tools],
                "path": str(s.path),
            }
            for s in self.skills.values()
        ]


def create_skill_template(skill_dir: Path, name: str) -> Path:
    """Create a new skill from template."""
    skill_path = skill_dir / name
    skill_path.mkdir(parents=True, exist_ok=True)

    # SKILL.md
    skill_md = f"""# {name.title()} Skill

Description of what this skill does.

## Usage

Explain how the agent should use this skill.

## Tools

List the tools this skill provides.
"""
    (skill_path / "SKILL.md").write_text(skill_md)

    # skill.py
    skill_py = f'''"""
{name.title()} Skill

Custom tools for {name}.
"""

from familiar.core.tools import Tool

# Define your tools here
TOOLS = [
    # Tool(
    #     name="{name}_action",
    #     description="Description of what this tool does",
    #     input_schema={{
    #         "type": "object",
    #         "properties": {{
    #             "param": {{"type": "string", "description": "Parameter description"}}
    #         }},
    #         "required": ["param"]
    #     }},
    #     handler=lambda data: f"Result: {{data['param']}}",
    #     category="{name}"
    # )
]


def register_tools():
    """Optional: Return additional tools dynamically."""
    return []
'''
    (skill_path / "skill.py").write_text(skill_py)

    # config.yaml
    config_yaml = f"""# {name.title()} Skill Configuration
enabled: true

# Add skill-specific settings here
"""
    (skill_path / "config.yaml").write_text(config_yaml)

    logger.info(f"Created skill template: {skill_path}")
    return skill_path
