"""Skills Deployer Service - Deploy Claude Code skills from GitHub.

WHY: Claude Code loads skills at STARTUP ONLY from ~/.claude/skills/ directory.
This service manages downloading skills from external GitHub repositories and
deploying them to Claude Code's skills directory with automatic restart warnings.

DESIGN DECISIONS:
- Downloads from https://github.com/bobmatnyc/claude-mpm-skills by default
- Deploys to ~/.claude/skills/ (Claude Code's directory), NOT project directory
- Integrates with ToolchainAnalyzer for automatic language detection
- Handles Claude Code restart requirement (skills only load at startup)
- Provides filtering by toolchain and categories
- Graceful error handling with actionable messages

ARCHITECTURE:
1. GitHub Download: Fetch ZIP archive from repository
2. Manifest Parsing: Read skill metadata from manifest.json
3. Filtering: Apply toolchain and category filters
4. Deployment: Copy skills to ~/.claude/skills/
5. Restart Detection: Warn if Claude Code is running
6. Cleanup: Remove temporary files

References:
- Research: docs/research/skills-research.md
- GitHub Repo: https://github.com/bobmatnyc/claude-mpm-skills
"""

import json
import platform
import shutil
import subprocess  # nosec B404 - subprocess needed for safe git operations
from pathlib import Path
from typing import Any, Dict, List, Optional

from claude_mpm.core.mixins import LoggerMixin
from claude_mpm.services.skills_config import SkillsConfig


class SkillsDeployerService(LoggerMixin):
    """Deploy Claude Code skills from external GitHub repositories.

    This service:
    - Downloads skills from GitHub repositories
    - Deploys to ~/.claude/skills/ directory
    - Filters by toolchain (python, javascript, rust, etc.)
    - Filters by categories (testing, debugging, web, etc.)
    - Detects Claude Code process and warns about restart requirement
    - Provides deployment summaries and error handling

    Example:
        >>> deployer = SkillsDeployerService()
        >>> result = deployer.deploy_skills(toolchain=['python'], categories=['testing'])
        >>> print(f"Deployed {result['deployed_count']} skills")
        >>> print(f"Restart Claude Code: {result['restart_required']}")
    """

    DEFAULT_REPO_URL = "https://github.com/bobmatnyc/claude-mpm-skills"
    CLAUDE_SKILLS_DIR = Path.home() / ".claude" / "skills"

    def __init__(
        self,
        repo_url: Optional[str] = None,
        toolchain_analyzer: Optional[any] = None,
    ):
        """Initialize Skills Deployer Service.

        Args:
            repo_url: GitHub repository URL (default: bobmatnyc/claude-mpm-skills)
            toolchain_analyzer: Optional ToolchainAnalyzer for auto-detection
        """
        super().__init__()
        self.repo_url = repo_url or self.DEFAULT_REPO_URL
        self.toolchain_analyzer = toolchain_analyzer
        self.skills_config = SkillsConfig()

        # Ensure Claude skills directory exists
        self.CLAUDE_SKILLS_DIR.mkdir(parents=True, exist_ok=True)

    def deploy_skills(
        self,
        collection: Optional[str] = None,
        toolchain: Optional[List[str]] = None,
        categories: Optional[List[str]] = None,
        force: bool = False,
        selective: bool = True,
        project_root: Optional[Path] = None,
        skill_names: Optional[List[str]] = None,
        skills_dir: Optional[Path] = None,
    ) -> Dict:
        """Deploy skills from GitHub repository.

        This is the main entry point for skill deployment. It:
        1. Downloads skills from GitHub collection
        2. Parses manifest for metadata
        3. Filters by toolchain and categories
        4. (If skill_names provided) Filters to only specified skills
        5. (If selective=True) Filters to only agent-referenced skills
        6. Deploys to target skills directory (default: ~/.claude/skills/)
        7. Warns about Claude Code restart

        Args:
            collection: Collection name to deploy from (default: uses default collection)
            toolchain: Filter by toolchain (e.g., ['python', 'javascript'])
            categories: Filter by categories (e.g., ['testing', 'debugging'])
            force: Overwrite existing skills
            selective: If True, only deploy skills referenced by agents (default)
            project_root: Project root directory (for finding agents, auto-detected if None)
            skill_names: Specific skill names to deploy (overrides selective filtering)
            skills_dir: Target directory for deployed skills (default: ~/.claude/skills/)

        Returns:
            Dict containing:
            - deployed_count: Number of skills deployed
            - skipped_count: Number of skills skipped
            - errors: List of error messages
            - deployed_skills: List of deployed skill names
            - restart_required: True if Claude Code needs restart
            - restart_instructions: Message about restarting
            - collection: Collection name used for deployment
            - selective_mode: True if selective deployment was used
            - total_available: Total skills available before filtering

        Example:
            >>> result = deployer.deploy_skills(collection="obra-superpowers")
            >>> result = deployer.deploy_skills(toolchain=['python'])  # Uses default
            >>> # Deploy to project-scoped directory
            >>> result = deployer.deploy_skills(skills_dir=Path("project/.claude/skills"))
            >>> # Deploy all skills (not just agent-referenced)
            >>> result = deployer.deploy_skills(selective=False)
            >>> if result['restart_required']:
            >>>     print(result['restart_instructions'])
        """
        # Use provided skills_dir or fall back to default
        target_skills_dir = (
            skills_dir if skills_dir is not None else self.CLAUDE_SKILLS_DIR
        )
        # Determine which collection to use
        collection_name = collection or self.skills_config.get_default_collection()

        self.logger.info(f"Deploying skills from collection '{collection_name}'")

        # Step 1: Download skills from GitHub collection
        try:
            skills_data = self._download_from_github(collection_name)
        except Exception as e:
            self.logger.error(f"Failed to download skills: {e}")
            return {
                "deployed_count": 0,
                "skipped_count": 0,
                "errors": [f"Download failed: {e}"],
                "deployed_skills": [],
                "restart_required": False,
                "restart_instructions": "",
                "collection": collection_name,
            }

        # Step 2: Parse manifest and flatten skills
        manifest = skills_data.get("manifest", {})
        try:
            skills = self._flatten_manifest_skills(manifest)
        except ValueError as e:
            self.logger.error(f"Invalid manifest structure: {e}")
            return {
                "deployed_count": 0,
                "skipped_count": 0,
                "errors": [f"Invalid manifest: {e}"],
                "deployed_skills": [],
                "restart_required": False,
                "restart_instructions": "",
                "collection": collection_name,
            }

        self.logger.info(f"Found {len(skills)} skills in repository")

        # Step 3: Filter skills by toolchain and categories
        filtered_skills = self._filter_skills(skills, toolchain, categories)

        self.logger.info(
            f"After filtering: {len(filtered_skills)} skills to deploy"
            f" (toolchain={toolchain}, categories={categories})"
        )

        # Step 3.5a: Filter by specific skill names if provided
        if skill_names:
            skill_names_set = set(skill_names)
            filtered_skills = [
                skill
                for skill in filtered_skills
                if skill.get("name") in skill_names_set
                or skill.get("skill_id") in skill_names_set
            ]
            self.logger.info(
                f"After skill_names filtering: {len(filtered_skills)} skills to deploy"
            )

        # Step 3.5b: Apply selective filtering (only agent-referenced skills)
        total_available = len(filtered_skills)
        if selective and not skill_names:
            # Auto-detect project root if not provided
            if project_root is None:
                # Try to find project root by looking for .claude-mpm directory
                # Start from current directory and walk up
                current = Path.cwd()
                while current != current.parent:
                    if (current / ".claude-mpm").exists():
                        project_root = current
                        break
                    current = current.parent

            # Read skills from configuration.yaml instead of agent frontmatter
            if project_root:
                config_path = Path(project_root) / ".claude-mpm" / "configuration.yaml"
            else:
                # Fallback to current directory's configuration
                config_path = Path.cwd() / ".claude-mpm" / "configuration.yaml"

            from claude_mpm.services.skills.selective_skill_deployer import (
                get_required_skills_from_agents,
                get_skills_to_deploy,
                save_agent_skills_to_config,
            )

            # Check if agent_referenced is empty and needs to be populated
            required_skill_names, source = get_skills_to_deploy(config_path)

            if not required_skill_names and project_root:
                # agent_referenced is empty, scan deployed agents to populate it
                agents_dir = Path(project_root) / ".claude" / "agents"
                if agents_dir.exists():
                    self.logger.info(
                        "agent_referenced is empty in configuration.yaml, scanning deployed agents..."
                    )
                    agent_skills = get_required_skills_from_agents(agents_dir)
                    if agent_skills:
                        save_agent_skills_to_config(list(agent_skills), config_path)
                        self.logger.info(
                            f"Populated agent_referenced with {len(agent_skills)} skills from deployed agents"
                        )
                        # Re-read configuration after update
                        required_skill_names, source = get_skills_to_deploy(config_path)
                    else:
                        self.logger.warning(
                            "No skills found in deployed agents - configuration.yaml remains empty"
                        )
                else:
                    self.logger.warning(
                        f"Agents directory not found at {agents_dir} - cannot scan for skills"
                    )

            if required_skill_names:
                # Convert required_skill_names to a set for O(1) lookup
                required_set = set(required_skill_names)

                # Filter to only required skills
                # Match on: 'name', 'skill_id', or normalized 'source_path'
                # source_path example: "universal/web/api-design-patterns/SKILL.md"
                # normalized: "universal-web-api-design-patterns"
                def skill_matches_requirement(skill):
                    # Check basic name and skill_id
                    if skill.get("name") in required_set:
                        return True
                    if skill.get("skill_id") in required_set:
                        return True

                    # Check normalized source_path
                    source_path = skill.get("source_path", "")
                    if source_path:
                        # Remove /SKILL.md suffix and replace / with -
                        normalized = source_path.replace("/SKILL.md", "").replace(
                            "/", "-"
                        )
                        if normalized in required_set:
                            return True

                    return False

                filtered_skills = [
                    s for s in filtered_skills if skill_matches_requirement(s)
                ]

                self.logger.info(
                    f"Selective deployment: {len(filtered_skills)}/{total_available} skills "
                    f"(source: {source})"
                )
            else:
                self.logger.warning(
                    f"No skills found in configuration at {config_path}. "
                    f"Deploying all {total_available} skills."
                )
        else:
            self.logger.info(
                f"Selective mode disabled: deploying all {total_available} skills"
            )

        # Step 4: Deploy skills
        deployed = []
        skipped = []
        errors = []

        # Create target directory if it doesn't exist
        target_skills_dir.mkdir(parents=True, exist_ok=True)

        # Extract normalized skill names for cleanup (needed regardless of deployment outcome)
        # Must match the names used during deployment (normalized from source_path)
        filtered_skills_names = []
        for skill in filtered_skills:
            if isinstance(skill, dict) and "name" in skill:
                source_path = skill.get("source_path", "")
                if source_path:
                    # Normalize: "universal/web/api-design-patterns/SKILL.md" -> "universal-web-api-design-patterns"
                    normalized = source_path.replace("/SKILL.md", "").replace("/", "-")
                    filtered_skills_names.append(normalized)
                else:
                    # Fallback to skill name
                    filtered_skills_names.append(skill["name"])

        for skill in filtered_skills:
            try:
                # Validate skill is a dictionary
                if not isinstance(skill, dict):
                    self.logger.error(f"Invalid skill format: {skill}")
                    errors.append(f"Invalid skill format: {skill}")
                    continue

                result = self._deploy_skill(
                    skill,
                    skills_data["temp_dir"],
                    collection_name,
                    force=force,
                    skills_dir=target_skills_dir,
                )
                if result["deployed"]:
                    # Use normalized name for reporting
                    source_path = skill.get("source_path", "")
                    if source_path:
                        normalized = source_path.replace("/SKILL.md", "").replace(
                            "/", "-"
                        )
                        deployed.append(normalized)
                    else:
                        deployed.append(skill["name"])
                elif result["skipped"]:
                    # Use normalized name for reporting
                    source_path = skill.get("source_path", "")
                    if source_path:
                        normalized = source_path.replace("/SKILL.md", "").replace(
                            "/", "-"
                        )
                        skipped.append(normalized)
                    else:
                        skipped.append(skill["name"])
                if result["error"]:
                    errors.append(result["error"])
            except Exception as e:
                skill_name = (
                    skill.get("name", "unknown")
                    if isinstance(skill, dict)
                    else "unknown"
                )
                self.logger.error(f"Failed to deploy {skill_name}: {e}")
                errors.append(f"{skill_name}: {e}")

        # Step 5: Cleanup orphaned skills (always run in selective mode)
        cleanup_result = {"removed_count": 0, "removed_skills": []}
        if selective:
            # Get the set of skills that should remain deployed
            # This is the union of what we just deployed and what was already there
            try:
                from claude_mpm.services.skills.selective_skill_deployer import (
                    cleanup_orphan_skills,
                )

                # Cleanup orphaned skills not referenced by agents
                # This runs even if nothing new was deployed to remove stale skills
                cleanup_result = cleanup_orphan_skills(
                    target_skills_dir, set(filtered_skills_names)
                )

                if cleanup_result["removed_count"] > 0:
                    self.logger.info(
                        f"Removed {cleanup_result['removed_count']} orphaned skills: "
                        f"{', '.join(cleanup_result['removed_skills'])}"
                    )
            except Exception as e:
                self.logger.warning(f"Failed to cleanup orphaned skills: {e}")

        # Step 6: Cleanup temp directory
        self._cleanup(skills_data["temp_dir"])

        # Step 7: Check if Claude Code restart needed
        restart_required = len(deployed) > 0
        restart_instructions = ""

        if restart_required:
            claude_running = self._is_claude_code_running()
            if claude_running:
                restart_instructions = (
                    "⚠️  Claude Code is currently running.\n"
                    "Skills are only loaded at STARTUP.\n"
                    "Please restart Claude Code for new skills to be available.\n\n"
                    "How to restart Claude Code:\n"
                    "1. Close all Claude Code windows\n"
                    "2. Quit Claude Code completely (Cmd+Q on Mac, Alt+F4 on Windows)\n"
                    "3. Re-launch Claude Code\n"
                )
            else:
                restart_instructions = (
                    "✓ Claude Code is not currently running.\n"
                    "Skills will be available when you launch Claude Code.\n"
                )

        self.logger.info(
            f"Deployment complete: {len(deployed)} deployed, "
            f"{len(skipped)} skipped, {len(errors)} errors, "
            f"{cleanup_result['removed_count']} orphaned skills removed"
        )

        return {
            "deployed_count": len(deployed),
            "skipped_count": len(skipped),
            "errors": errors,
            "deployed_skills": deployed,
            "skipped_skills": skipped,
            "restart_required": restart_required,
            "restart_instructions": restart_instructions,
            "collection": collection_name,
            "selective_mode": selective,
            "total_available": total_available,
            "cleanup": cleanup_result,
        }

    def list_available_skills(self, collection: Optional[str] = None) -> Dict:
        """List all available skills from GitHub repository.

        Downloads manifest and returns skill metadata grouped by category
        and toolchain.

        Args:
            collection: Collection name to list from (default: uses default collection)

        Returns:
            Dict containing:
            - total_skills: Total number of available skills
            - by_category: Skills grouped by category
            - by_toolchain: Skills grouped by toolchain
            - skills: Full list of skill metadata
            - collection: Collection name used

        Example:
            >>> result = deployer.list_available_skills(collection="obra-superpowers")
            >>> result = deployer.list_available_skills()  # Uses default
            >>> print(f"Available: {result['total_skills']} skills")
            >>> for category, skills in result['by_category'].items():
            >>>     print(f"{category}: {len(skills)} skills")
        """
        collection_name = collection or self.skills_config.get_default_collection()

        try:
            skills_data = self._download_from_github(collection_name)
            manifest = skills_data.get("manifest", {})

            # Flatten skills from manifest (supports both legacy and new structure)
            try:
                skills = self._flatten_manifest_skills(manifest)
            except ValueError as e:
                self.logger.error(f"Failed to parse manifest: {e}")
                return {
                    "total_skills": 0,
                    "by_category": {},
                    "by_toolchain": {},
                    "skills": [],
                    "error": str(e),
                }

            # Group by category
            by_category = {}
            for skill in skills:
                if not isinstance(skill, dict):
                    continue
                category = skill.get("category", "uncategorized")
                if category not in by_category:
                    by_category[category] = []
                by_category[category].append(skill)

            # Group by toolchain
            by_toolchain = {}
            for skill in skills:
                if not isinstance(skill, dict):
                    continue
                toolchains = skill.get("toolchain", [])
                if isinstance(toolchains, str):
                    toolchains = [toolchains]
                elif not isinstance(toolchains, list):
                    toolchains = []

                for toolchain in toolchains:
                    if toolchain not in by_toolchain:
                        by_toolchain[toolchain] = []
                    by_toolchain[toolchain].append(skill)

            # Cleanup
            self._cleanup(skills_data["temp_dir"])

            return {
                "total_skills": len(skills),
                "by_category": by_category,
                "by_toolchain": by_toolchain,
                "skills": skills,
                "collection": collection_name,
            }

        except Exception as e:
            self.logger.error(f"Failed to list available skills: {e}")
            return {
                "total_skills": 0,
                "by_category": {},
                "by_toolchain": {},
                "skills": [],
                "error": str(e),
                "collection": collection_name,
            }

    def check_deployed_skills(self, skills_dir: Optional[Path] = None) -> Dict:
        """Check which skills are currently deployed.

        Scans the given skills directory (or ~/.claude/skills/ by default)
        for deployed skills.

        Args:
            skills_dir: Directory to scan for deployed skills.
                        Defaults to self.CLAUDE_SKILLS_DIR (~/.claude/skills/).
                        Pass Path.cwd() / ".claude" / "skills" for project-level skills.

        Returns:
            Dict containing:
            - deployed_count: Number of deployed skills
            - skills: List of deployed skill names with paths
            - claude_skills_dir: Path to the scanned skills directory

        Example:
            >>> result = deployer.check_deployed_skills()
            >>> print(f"Currently deployed: {result['deployed_count']} skills")
            >>> # Scan project-level skills instead
            >>> result = deployer.check_deployed_skills(Path.cwd() / ".claude" / "skills")
        """
        scan_dir = skills_dir if skills_dir is not None else self.CLAUDE_SKILLS_DIR
        deployed_skills = []

        if scan_dir.exists():
            for skill_dir in scan_dir.iterdir():
                if skill_dir.is_dir() and not skill_dir.name.startswith("."):
                    # Check for SKILL.md
                    skill_md = skill_dir / "SKILL.md"
                    if skill_md.exists():
                        deployed_skills.append(
                            {"name": skill_dir.name, "path": str(skill_dir)}
                        )

        return {
            "deployed_count": len(deployed_skills),
            "skills": deployed_skills,
            "claude_skills_dir": str(scan_dir),
        }

    def remove_skills(self, skill_names: Optional[List[str]] = None) -> Dict:
        """Remove deployed skills.

        Args:
            skill_names: List of skill names to remove, or None to remove all

        Returns:
            Dict containing:
            - removed_count: Number of skills removed
            - removed_skills: List of removed skill names
            - errors: List of error messages

        Example:
            >>> # Remove specific skills
            >>> result = deployer.remove_skills(['test-skill', 'debug-skill'])
            >>> # Remove all skills
            >>> result = deployer.remove_skills()
        """
        removed = []
        errors = []

        if not self.CLAUDE_SKILLS_DIR.exists():
            return {
                "removed_count": 0,
                "removed_skills": [],
                "errors": ["Claude skills directory does not exist"],
            }

        # Get all skills if no specific names provided
        if skill_names is None:
            skill_names = [
                d.name
                for d in self.CLAUDE_SKILLS_DIR.iterdir()
                if d.is_dir() and not d.name.startswith(".")
            ]

        for skill_name in skill_names:
            skill_dir = self.CLAUDE_SKILLS_DIR / skill_name

            if not skill_dir.exists():
                errors.append(f"Skill not found: {skill_name}")
                continue

            try:
                # Security: Validate path is within CLAUDE_SKILLS_DIR
                if not self._validate_safe_path(self.CLAUDE_SKILLS_DIR, skill_dir):
                    raise ValueError(f"Path traversal attempt detected: {skill_dir}")

                # Remove skill directory
                if skill_dir.is_symlink():
                    self.logger.warning(f"Removing symlink: {skill_dir}")
                    skill_dir.unlink()
                else:
                    shutil.rmtree(skill_dir)

                removed.append(skill_name)
                self.logger.info(f"Removed skill: {skill_name}")

                # Untrack skill from deployment index
                from claude_mpm.services.skills.selective_skill_deployer import (
                    untrack_skill,
                )

                untrack_skill(self.CLAUDE_SKILLS_DIR, skill_name)

            except Exception as e:
                self.logger.error(f"Failed to remove {skill_name}: {e}")
                errors.append(f"{skill_name}: {e}")

        return {
            "removed_count": len(removed),
            "removed_skills": removed,
            "errors": errors,
        }

    def _download_from_github(self, collection_name: str) -> Dict:
        """Download skills repository from GitHub using git clone/pull.

        Logic:
        1. Get collection config from SkillsConfig
        2. Determine target directory: ~/.claude/skills/{collection_name}/
        3. Check if directory exists:
           - Exists + is git repo → git pull (update)
           - Exists + not git repo → error (manual cleanup needed)
           - Not exists → git clone (first install)
        4. Parse manifest.json from collection
        5. Update last_update timestamp in config
        6. Return skills data

        Args:
            collection_name: Name of collection to download

        Returns:
            Dict containing:
            - temp_dir: Path to collection directory (not temp, but kept for compatibility)
            - manifest: Parsed manifest.json
            - repo_dir: Path to repository directory

        Raises:
            ValueError: If collection not found or disabled
            Exception: If git operations fail
        """
        # Get collection configuration
        collection_config = self.skills_config.get_collection(collection_name)
        if not collection_config:
            raise ValueError(
                f"Collection '{collection_name}' not found. "
                f"Use 'claude-mpm skills collection add' to add it."
            )

        if not collection_config.get("enabled", True):
            raise ValueError(
                f"Collection '{collection_name}' is disabled. "
                f"Use 'claude-mpm skills collection enable {collection_name}' to enable it."
            )

        repo_url = collection_config["url"]
        target_dir = self.CLAUDE_SKILLS_DIR / collection_name

        self.logger.info(f"Processing collection '{collection_name}' from {repo_url}")

        # Check if directory exists and handle accordingly
        if target_dir.exists():
            git_dir = target_dir / ".git"

            if git_dir.exists():
                # Update existing: git pull
                self.logger.info(
                    f"Updating existing collection '{collection_name}' at {target_dir}"
                )
                try:
                    result = subprocess.run(  # nosec B603 B607 - Safe: hardcoded git command
                        ["git", "pull"],
                        cwd=target_dir,
                        capture_output=True,
                        text=True,
                        check=True,
                        timeout=60,
                    )
                    self.logger.debug(f"Git pull output: {result.stdout}")

                except subprocess.CalledProcessError as e:
                    raise Exception(
                        f"Failed to update collection '{collection_name}': {e.stderr}"
                    ) from e
                except subprocess.TimeoutExpired as e:
                    raise Exception(
                        f"Git pull timed out for collection '{collection_name}'"
                    ) from e
            else:
                # Directory exists but not a git repo - error
                raise ValueError(
                    f"Directory {target_dir} exists but is not a git repository. "
                    f"Please remove it manually and try again:\n"
                    f"  rm -rf {target_dir}"
                )
        else:
            # First install: git clone
            self.logger.info(
                f"Installing new collection '{collection_name}' to {target_dir}"
            )
            try:
                result = subprocess.run(  # nosec B603 B607 - Safe: hardcoded git command
                    ["git", "clone", repo_url, str(target_dir)],
                    capture_output=True,
                    text=True,
                    check=True,
                    timeout=120,
                )
                self.logger.debug(f"Git clone output: {result.stdout}")

            except subprocess.CalledProcessError as e:
                raise Exception(
                    f"Failed to clone collection '{collection_name}': {e.stderr}"
                ) from e
            except subprocess.TimeoutExpired as e:
                raise Exception(
                    f"Git clone timed out for collection '{collection_name}'"
                ) from e

        # Update last_update timestamp
        self.skills_config.update_collection_timestamp(collection_name)

        # Parse manifest.json
        manifest_path = target_dir / "manifest.json"
        if not manifest_path.exists():
            raise Exception(
                f"manifest.json not found in collection '{collection_name}' at {target_dir}"
            )

        try:
            with open(manifest_path, encoding="utf-8") as f:
                manifest = json.load(f)
        except json.JSONDecodeError as e:
            raise Exception(
                f"Invalid manifest.json in collection '{collection_name}': {e}"
            ) from e

        self.logger.info(
            f"Successfully loaded collection '{collection_name}' from {target_dir}"
        )

        # Return data in same format as before for compatibility
        # Note: temp_dir is now the persistent collection directory
        return {"temp_dir": target_dir, "manifest": manifest, "repo_dir": target_dir}

    def _flatten_manifest_skills(self, manifest: Dict) -> List[Dict]:
        """Flatten skills from manifest, supporting both structures.

        Supports both legacy flat list and new nested dict structures:
        - Legacy: {"skills": [skill1, skill2, ...]}
        - New: {"skills": {"universal": [...], "toolchains": {...}}}

        Args:
            manifest: The manifest dictionary

        Returns:
            List of flattened skill dictionaries

        Raises:
            ValueError: If manifest structure is invalid

        Example:
            >>> # Legacy flat structure
            >>> manifest = {"skills": [{"name": "skill1"}, {"name": "skill2"}]}
            >>> skills = deployer._flatten_manifest_skills(manifest)
            >>> len(skills)  # 2

            >>> # New nested structure
            >>> manifest = {
            ...     "skills": {
            ...         "universal": [{"name": "skill1"}],
            ...         "toolchains": {"python": [{"name": "skill2"}]}
            ...     }
            ... }
            >>> skills = deployer._flatten_manifest_skills(manifest)
            >>> len(skills)  # 2
        """
        skills_data = manifest.get("skills", {})

        # Handle legacy flat list structure
        if isinstance(skills_data, list):
            self.logger.debug(
                f"Using legacy flat manifest structure ({len(skills_data)} skills)"
            )
            return skills_data

        # Handle new nested dict structure
        if isinstance(skills_data, dict):
            flat_skills = []

            # Define valid top-level categories
            VALID_CATEGORIES = {"universal", "toolchains"}

            # Check for unknown categories and warn user
            unknown_categories = set(skills_data.keys()) - VALID_CATEGORIES
            if unknown_categories:
                # Count skills in unknown categories
                skipped_count = 0
                for cat in unknown_categories:
                    cat_data = skills_data.get(cat, [])
                    if isinstance(cat_data, list):
                        skipped_count += len(cat_data)
                    elif isinstance(cat_data, dict):
                        # If it's a dict like toolchains, count nested skills
                        for skills_list in cat_data.values():
                            if isinstance(skills_list, list):
                                skipped_count += len(skills_list)

                self.logger.warning(
                    f"Unknown categories in manifest will be skipped: "
                    f"{', '.join(sorted(unknown_categories))} ({skipped_count} skills)"
                )
                self.logger.info(
                    f"Valid top-level categories: {', '.join(sorted(VALID_CATEGORIES))}"
                )

            # Add universal skills
            universal_skills = skills_data.get("universal", [])
            if isinstance(universal_skills, list):
                flat_skills.extend(universal_skills)
                self.logger.debug(f"Added {len(universal_skills)} universal skills")

            # Add toolchain-specific skills
            toolchains = skills_data.get("toolchains", {})
            if isinstance(toolchains, dict):
                for toolchain_name, toolchain_skills in toolchains.items():
                    if isinstance(toolchain_skills, list):
                        flat_skills.extend(toolchain_skills)
                        self.logger.debug(
                            f"Added {len(toolchain_skills)} {toolchain_name} skills"
                        )

            self.logger.info(
                f"Flattened {len(flat_skills)} total skills from nested structure"
            )
            return flat_skills

        # Invalid structure
        raise ValueError(
            f"Skills manifest must be a list or dict, got {type(skills_data).__name__}"
        )

    def _filter_skills(
        self,
        skills: List[Dict],
        toolchain: Optional[List[str]] = None,
        categories: Optional[List[str]] = None,
    ) -> List[Dict]:
        """Filter skills by toolchain and categories.

        Args:
            skills: List of skill metadata dicts
            toolchain: List of toolchains to include (None = all)
            categories: List of categories to include (None = all)

        Returns:
            Filtered list of skills
        """
        # Ensure skills is a list and contains dicts
        if not isinstance(skills, list):
            return []

        # Filter out non-dict items
        filtered = [s for s in skills if isinstance(s, dict)]

        # Filter by toolchain
        if toolchain:
            toolchain_lower = [t.lower() for t in toolchain]
            filtered = [
                s
                for s in filtered
                if isinstance(s, dict)
                and any(
                    t.lower() in toolchain_lower
                    for t in (
                        s.get("toolchain", [])
                        if isinstance(s.get("toolchain"), list)
                        else ([s.get("toolchain")] if s.get("toolchain") else [])
                    )
                )
            ]

        # Filter by categories
        if categories:
            categories_lower = [c.lower() for c in categories]
            filtered = [
                s
                for s in filtered
                if isinstance(s, dict)
                and s.get("category", "").lower() in categories_lower
            ]

        return filtered

    def _deploy_skill(
        self,
        skill: Dict,
        collection_dir: Path,
        collection_name: str,
        force: bool = False,
        skills_dir: Optional[Path] = None,
    ) -> Dict:
        """Deploy a single skill to target skills directory and track deployment.

        NOTE: With multi-collection support, skills are now stored in collection
        subdirectories. This method creates symlinks or copies to maintain the
        flat structure that Claude Code expects in the target skills directory.

        Additionally tracks deployed skills in .mpm-deployed-skills.json index
        for orphan cleanup functionality.

        Args:
            skill: Skill metadata dict
            collection_dir: Collection directory containing skills
            collection_name: Name of collection (for tracking)
            force: Overwrite if already exists
            skills_dir: Target directory for deployed skills (default: ~/.claude/skills/)

        Returns:
            Dict with deployed, skipped, error flags
        """
        # Use provided skills_dir or fall back to default
        target_skills_dir = (
            skills_dir if skills_dir is not None else self.CLAUDE_SKILLS_DIR
        )

        skill_name = skill["name"]

        # Use normalized source_path for both target directory and deployment tracking
        # This ensures consistency with configuration.yaml skill names
        source_path = skill.get("source_path", "")
        if source_path:
            # Normalize: "universal/web/api-design-patterns/SKILL.md" -> "universal-web-api-design-patterns"
            normalized_name = source_path.replace("/SKILL.md", "").replace("/", "-")
            target_dir = target_skills_dir / normalized_name
        else:
            # Fallback to skill name if no source_path
            target_dir = target_skills_dir / skill_name

        # Check if already deployed
        if target_dir.exists() and not force:
            self.logger.debug(f"Skipped {skill_name} (already deployed)")
            return {"deployed": False, "skipped": True, "error": None}

        # Find skill source using source_path from manifest
        source_dir = None

        if source_path:
            # Direct lookup using source_path (most reliable)
            # Example: "universal/web/api-design-patterns/SKILL.md" -> "universal/web/api-design-patterns"
            skill_dir_path = source_path.replace("/SKILL.md", "")
            potential_source = collection_dir / skill_dir_path
            if potential_source.exists():
                source_dir = potential_source
            else:
                self.logger.debug(
                    f"Source path {skill_dir_path} not found, trying fallback search"
                )

        # Fallback: search using old logic (for backward compatibility)
        if not source_dir:
            skills_base = collection_dir / "skills"
            category = skill.get("category", "")

            # Try multiple possible locations
            search_paths = []

            # Try category-based path
            if category and skills_base.exists():
                search_paths.append(skills_base / category / skill_name)

            # Try universal/toolchains structure
            if (collection_dir / "universal").exists():
                search_paths.append(collection_dir / "universal" / skill_name)

            if (collection_dir / "toolchains").exists():
                toolchain_dir = collection_dir / "toolchains"
                for tc in toolchain_dir.iterdir():
                    if tc.is_dir():
                        search_paths.append(tc / skill_name)

            # Search in all possible locations
            for path in search_paths:
                if path.exists():
                    source_dir = path
                    break

            # Final fallback: search recursively for skill in skills directory
            if not source_dir and skills_base.exists():
                for cat_dir in skills_base.iterdir():
                    if not cat_dir.is_dir():
                        continue
                    potential = cat_dir / skill_name
                    if potential.exists():
                        source_dir = potential
                        break

        if not source_dir or not source_dir.exists():
            return {
                "deployed": False,
                "skipped": False,
                "error": f"Skill source not found: {skill_name} (searched in {collection_dir})",
            }

        # Security: Validate paths
        if not self._validate_safe_path(collection_dir, source_dir):
            return {
                "deployed": False,
                "skipped": False,
                "error": f"Invalid source path: {source_dir}",
            }

        if not self._validate_safe_path(target_skills_dir, target_dir):
            return {
                "deployed": False,
                "skipped": False,
                "error": f"Invalid target path: {target_dir}",
            }

        try:
            # Remove existing if force
            if target_dir.exists():
                if target_dir.is_symlink():
                    target_dir.unlink()
                else:
                    shutil.rmtree(target_dir)

            # Copy skill to Claude skills directory
            # NOTE: We use copy instead of symlink to maintain Claude Code compatibility
            shutil.copytree(source_dir, target_dir)

            # Track deployment in index using normalized name
            from claude_mpm.services.skills.selective_skill_deployer import (
                track_deployed_skill,
            )

            # Use normalized name for tracking (matches configuration.yaml format)
            track_name = normalized_name if source_path else skill_name
            track_deployed_skill(target_skills_dir, track_name, collection_name)

            self.logger.debug(
                f"Deployed {skill_name} from {source_dir} to {target_dir}"
            )
            return {"deployed": True, "skipped": False, "error": None}

        except Exception as e:
            return {"deployed": False, "skipped": False, "error": str(e)}

    def _validate_safe_path(self, base: Path, target: Path) -> bool:
        """Ensure target path is within base directory (security).

        Args:
            base: Base directory
            target: Target path to validate

        Returns:
            True if path is safe, False otherwise
        """
        try:
            target.resolve().relative_to(base.resolve())
            return True
        except ValueError:
            return False

    def _is_claude_code_running(self) -> bool:
        """Check if Claude Code process is running.

        Returns:
            True if Claude Code is running, False otherwise
        """
        try:
            if platform.system() == "Windows":
                result = subprocess.run(  # nosec B603 B607 - Safe: hardcoded tasklist command
                    ["tasklist"], check=False, capture_output=True, text=True, timeout=5
                )
                return "claude" in result.stdout.lower()
            # macOS and Linux
            result = subprocess.run(  # nosec B603 B607 - Safe: hardcoded ps command
                ["ps", "aux"], check=False, capture_output=True, text=True, timeout=5
            )
            # Look for "Claude Code" or "claude-code" process
            return (
                "claude code" in result.stdout.lower()
                or "claude-code" in result.stdout.lower()
            )

        except Exception as e:
            self.logger.debug(f"Failed to check Claude Code process: {e}")
            return False

    def _cleanup(self, temp_dir: Path) -> None:
        """Cleanup temporary directory.

        NOTE: With multi-collection support, temp_dir is now the persistent
        collection directory, so we DON'T delete it. This method is kept for
        backward compatibility but is now a no-op.

        Args:
            temp_dir: Collection directory (not deleted)
        """
        # NO-OP: Collection directories are persistent, not temporary
        # Skills are deployed from collection directories to Claude skills dir
        self.logger.debug(f"Collection directory preserved at {temp_dir} (not deleted)")

    # === Collection Management Methods ===

    def list_collections(self) -> Dict[str, Any]:
        """List all configured skill collections.

        Returns:
            Dict containing:
            - collections: Dict of collection configurations
            - default_collection: Name of default collection
            - enabled_count: Number of enabled collections

        Example:
            >>> result = deployer.list_collections()
            >>> for name, config in result['collections'].items():
            >>>     print(f"{name}: {config['url']} (priority: {config['priority']})")
        """
        collections = self.skills_config.get_collections()
        default = self.skills_config.get_default_collection()
        enabled = self.skills_config.get_enabled_collections()

        return {
            "collections": collections,
            "default_collection": default,
            "enabled_count": len(enabled),
            "total_count": len(collections),
        }

    def add_collection(self, name: str, url: str, priority: int = 99) -> Dict[str, Any]:
        """Add a new skill collection.

        Args:
            name: Collection name (must be unique)
            url: GitHub repository URL
            priority: Collection priority (lower = higher priority, default: 99)

        Returns:
            Dict with operation result

        Example:
            >>> deployer.add_collection("obra-superpowers", "https://github.com/obra/superpowers")
        """
        return self.skills_config.add_collection(name, url, priority)

    def remove_collection(self, name: str) -> Dict[str, Any]:
        """Remove a skill collection.

        Args:
            name: Collection name to remove

        Returns:
            Dict with operation result

        Example:
            >>> deployer.remove_collection("obra-superpowers")
        """
        result = self.skills_config.remove_collection(name)

        # Also remove the collection directory
        collection_dir = self.CLAUDE_SKILLS_DIR / name
        if collection_dir.exists():
            try:
                shutil.rmtree(collection_dir)
                self.logger.info(f"Removed collection directory: {collection_dir}")
                result["directory_removed"] = True
            except Exception as e:
                self.logger.warning(f"Failed to remove directory {collection_dir}: {e}")
                result["directory_removed"] = False
                result["directory_error"] = str(e)

        return result

    def enable_collection(self, name: str) -> Dict[str, Any]:
        """Enable a disabled collection.

        Args:
            name: Collection name

        Returns:
            Dict with operation result

        Example:
            >>> deployer.enable_collection("obra-superpowers")
        """
        return self.skills_config.enable_collection(name)

    def disable_collection(self, name: str) -> Dict[str, Any]:
        """Disable a collection without removing it.

        Args:
            name: Collection name

        Returns:
            Dict with operation result

        Example:
            >>> deployer.disable_collection("obra-superpowers")
        """
        return self.skills_config.disable_collection(name)

    def set_default_collection(self, name: str) -> Dict[str, Any]:
        """Set the default collection for deployments.

        Args:
            name: Collection name to set as default

        Returns:
            Dict with operation result

        Example:
            >>> deployer.set_default_collection("obra-superpowers")
        """
        return self.skills_config.set_default_collection(name)
