#!/usr/bin/env python3
"""
Skill Validation Script for Claude Skills Repository

Validates skill structure, YAML frontmatter, and count consistency.
Run before releases to prevent broken skills from being published.

Usage:
    python scripts/validate-skills.py              # Run all checks
    python scripts/validate-skills.py --check yaml # YAML-related checks only
    python scripts/validate-skills.py --check references  # Reference checks only
    python scripts/validate-skills.py --check workflows   # Workflow definition checks only
    python scripts/validate-skills.py --check crossrefs   # Cross-reference validation only
    python scripts/validate-skills.py --skill react-expert  # Single skill
    python scripts/validate-skills.py --format json  # JSON for CI

Exit codes:
    0 = Success (warnings allowed)
    1 = Errors found
"""

from abc import ABC, abstractmethod
import argparse
from dataclasses import dataclass, field
from enum import Enum, IntEnum
import json
from pathlib import Path
import re
import sys

# Try to import PyYAML, fall back to simple parser if not available
try:
    import yaml

    HAS_PYYAML = True
except ImportError:
    HAS_PYYAML = False


def simple_yaml_parse(yaml_str: str) -> dict:
    """
    Simple YAML frontmatter parser for skill files.
    Handles the basic structure used in this project:
    - Simple key: value pairs
    - Lists with - prefix
    - One level of nested mappings (e.g., metadata: with indented key: value)
    """
    result = {}
    current_key = None
    current_collection = None  # list or dict
    collection_type = None  # "list" or "dict" or None (undetermined)

    def _save_current():
        nonlocal current_key, current_collection, collection_type
        if current_key and current_collection is not None:
            result[current_key] = current_collection
        current_key = None
        current_collection = None
        collection_type = None

    for line in yaml_str.strip().split("\n"):
        # Skip empty lines
        if not line.strip():
            continue

        # Check for list item (indented with -)
        if line.startswith("  - ") or line.startswith("    - "):
            if current_key is not None:
                if collection_type is None:
                    # First child is a list item — this is a list
                    current_collection = []
                    collection_type = "list"
                if collection_type == "list":
                    item = line.strip().lstrip("- ").strip()
                    current_collection.append(item)  # type: ignore[union-attr]
            continue

        # Check for nested key: value (indented, part of a mapping)
        if line.startswith("  ") and ":" in line and not line.startswith("  - "):
            if current_key is not None:
                if collection_type is None:
                    # First child is a key: value — this is a dict
                    current_collection = {}
                    collection_type = "dict"
                if collection_type == "dict":
                    nested_parts = line.strip().split(":", 1)
                    nested_key = nested_parts[0].strip()
                    nested_value = nested_parts[1].strip() if len(nested_parts) > 1 else ""
                    # Strip surrounding quotes from values
                    if nested_value.startswith('"') and nested_value.endswith('"'):
                        nested_value = nested_value[1:-1]
                    current_collection[nested_key] = nested_value  # type: ignore[index]
            continue

        # Check for top-level key: value pair
        if ":" in line and not line.startswith(" "):
            _save_current()

            parts = line.split(":", 1)
            key = parts[0].strip()
            value = parts[1].strip() if len(parts) > 1 else ""

            if not value:
                # Starts a collection — type determined by first child
                current_key = key
                current_collection = None
                collection_type = None
            else:
                # Strip surrounding quotes from values
                if value.startswith('"') and value.endswith('"'):
                    value = value[1:-1]
                result[key] = value

    # Save any remaining collection
    if current_key is not None:
        if current_collection is None:
            # Key with no children — store as empty dict
            result[current_key] = {}
        else:
            result[current_key] = current_collection

    return result


def parse_yaml(yaml_str: str) -> dict:
    """Parse YAML using PyYAML if available, otherwise use simple parser."""
    if HAS_PYYAML:
        return yaml.safe_load(yaml_str) or {}
    return simple_yaml_parse(yaml_str)


# =============================================================================
# Constants
# =============================================================================

SKILLS_DIR = "skills"
REQUIRED_FIELDS = ["name", "description"]
MAX_DESCRIPTION_LENGTH = 1024
DESCRIPTION_TRIGGER = "Use when"
NAME_PATTERN = re.compile(r"^[a-zA-Z0-9-]+$")

# Required metadata sub-fields (under the metadata key)
REQUIRED_METADATA_FIELDS = ["triggers", "role", "scope", "output-format", "domain", "related-skills"]

# Known domain values (warning, not error, for unknown)
KNOWN_DOMAINS = {
    "language",
    "backend",
    "frontend",
    "infrastructure",
    "api-architecture",
    "quality",
    "devops",
    "security",
    "data-ml",
    "platform",
    "specialized",
    "workflow",
}

# Valid enum values for metadata fields
VALID_SCOPES = {
    "implementation",
    "review",
    "design",
    "system-design",
    "analysis",
    "testing",
    "infrastructure",
    "optimization",
    "architecture",
}

VALID_OUTPUT_FORMATS = {
    "code",
    "document",
    "report",
    "architecture",
    "analysis",
    "manifests",
    "specification",
    "schema",
    "analysis-and-code",
}

# Canonical section order (H2 headers)
CANONICAL_SECTIONS = [
    "Role Definition",
    "When to Use This Skill",
    "Core Workflow",
    "Reference Guide",
    "Constraints",
    "Output Templates",
    "Knowledge Reference",
    "Related Skills",
]

# Line count thresholds for SKILL.md
MIN_NON_BLANK_LINES = 80
MAX_NON_BLANK_LINES = 100

# Compiled regex patterns for body content checks
CORE_WORKFLOW_PATTERN = re.compile(r"##\s*Core\s+Workflow")
WHEN_TO_USE_PATTERN = re.compile(r"##\s*When\s+to\s+Use(?:\s+This\s+Skill)?", re.IGNORECASE)
NUMBERED_STEP_PATTERN = re.compile(r"^\d+\.\s", re.MULTILINE)
BULLET_PATTERN = re.compile(r"^\s*[-*]\s")
NEXT_SECTION_PATTERN = re.compile(r"\n##\s+")
H2_HEADER_PATTERN = re.compile(r"^##\s+(.+)$", re.MULTILINE)

# Files to check for count consistency
COUNT_FILES = [
    ".claude-plugin/plugin.json",
    ".claude-plugin/marketplace.json",
    "README.md",
    "ROADMAP.md",
    "QUICKSTART.md",
    "assets/social-preview.html",
]


# =============================================================================
# Workflow Constants
# =============================================================================

COMMANDS_DIR_WORKFLOW = "commands"
MANIFEST_FILE = "commands/workflow-manifest.yaml"

# Required fields in per-command YAML definitions
REQUIRED_DEFINITION_FIELDS = [
    "command",
    "path",
    "description",
    "inputs",
    "outputs",
    "requires",
]

# Valid values for workflow definition fields
VALID_INPUT_TYPES = {"string", "url", "list[url]", "list[string]", "flag", "file[]"}
VALID_OUTPUT_TYPES = {"url", "document", "tickets", "report", "file", "directory"}
VALID_REQUIRES = {"ticketing", "documentation"}
VALID_STATUS = {"existing", "planned", "deprecated"}
VALID_PHASES = {"intake", "discovery", "planning", "execution", "retrospective"}
VALID_DEPENDENCY_STRENGTHS = {"required", "recommended"}


# =============================================================================
# Data Classes
# =============================================================================


class Severity(Enum):
    ERROR = "error"
    WARNING = "warning"


class DFSColor(IntEnum):
    """Colors for DFS cycle detection."""

    WHITE = 0  # Unvisited
    GRAY = 1  # In progress
    BLACK = 2  # Completed


@dataclass
class FrontmatterResult:
    """Result of frontmatter extraction."""

    frontmatter: dict
    body: str
    skill_md: Path


@dataclass
class ValidationIssue:
    """Individual validation issue."""

    skill: str
    check: str
    severity: Severity
    message: str
    file: str | None = None

    def to_dict(self) -> dict:
        return {
            "skill": self.skill,
            "check": self.check,
            "severity": self.severity.value,
            "message": self.message,
            "file": self.file,
        }


@dataclass
class ValidationResult:
    """Per-skill validation results."""

    skill: str
    issues: list[ValidationIssue] = field(default_factory=list)

    @property
    def has_errors(self) -> bool:
        return any(i.severity == Severity.ERROR for i in self.issues)

    @property
    def has_warnings(self) -> bool:
        return any(i.severity == Severity.WARNING for i in self.issues)

    def to_dict(self) -> dict:
        return {
            "skill": self.skill,
            "issues": [i.to_dict() for i in self.issues],
            "has_errors": self.has_errors,
            "has_warnings": self.has_warnings,
        }


@dataclass
class ValidationReport:
    """Full validation report."""

    results: list[ValidationResult] = field(default_factory=list)
    count_issues: list[ValidationIssue] = field(default_factory=list)
    workflow_issues: list[ValidationIssue] = field(default_factory=list)
    crossref_issues: list[ValidationIssue] = field(default_factory=list)

    @property
    def has_errors(self) -> bool:
        return (
            any(r.has_errors for r in self.results)
            or any(i.severity == Severity.ERROR for i in self.count_issues)
            or any(i.severity == Severity.ERROR for i in self.workflow_issues)
            or any(i.severity == Severity.ERROR for i in self.crossref_issues)
        )

    @property
    def total_errors(self) -> int:
        count = sum(1 for r in self.results for i in r.issues if i.severity == Severity.ERROR)
        count += sum(1 for i in self.count_issues if i.severity == Severity.ERROR)
        count += sum(1 for i in self.workflow_issues if i.severity == Severity.ERROR)
        count += sum(1 for i in self.crossref_issues if i.severity == Severity.ERROR)
        return count

    @property
    def total_warnings(self) -> int:
        count = sum(1 for r in self.results for i in r.issues if i.severity == Severity.WARNING)
        count += sum(1 for i in self.count_issues if i.severity == Severity.WARNING)
        count += sum(1 for i in self.workflow_issues if i.severity == Severity.WARNING)
        count += sum(1 for i in self.crossref_issues if i.severity == Severity.WARNING)
        return count

    def to_dict(self) -> dict:
        return {
            "results": [r.to_dict() for r in self.results],
            "count_issues": [i.to_dict() for i in self.count_issues],
            "workflow_issues": [i.to_dict() for i in self.workflow_issues],
            "crossref_issues": [i.to_dict() for i in self.crossref_issues],
            "summary": {
                "total_skills": len(self.results),
                "total_errors": self.total_errors,
                "total_warnings": self.total_warnings,
                "has_errors": self.has_errors,
            },
        }


# =============================================================================
# Checker Classes (Strategy Pattern)
# =============================================================================


class BaseChecker(ABC):
    """Abstract base class for skill checkers."""

    name: str = "base"
    category: str = "general"

    @staticmethod
    def _extract_frontmatter(skill_path: Path) -> FrontmatterResult | None:
        """Extract frontmatter safely, returning None if invalid."""
        skill_md = skill_path / "SKILL.md"
        if not skill_md.exists():
            return None
        content = skill_md.read_text()
        if not content.startswith("---"):
            return None
        parts = content.split("---", 2)
        if len(parts) < 3:
            return None
        try:
            frontmatter = parse_yaml(parts[1])
            if frontmatter is None:
                return None
            return FrontmatterResult(frontmatter, parts[2], skill_md)
        except Exception:
            return None

    @abstractmethod
    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        """Run the check and return any issues found."""
        pass


class YamlChecker(BaseChecker):
    """Validates YAML frontmatter parsing."""

    name = "yaml"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        issues = []
        skill_md = skill_path / "SKILL.md"

        if not skill_md.exists():
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="Missing SKILL.md file",
                    file=str(skill_md),
                )
            )
            return issues

        content = skill_md.read_text()
        if not content.startswith("---"):
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="SKILL.md does not start with YAML frontmatter (---)",
                    file=str(skill_md),
                )
            )
            return issues

        parts = content.split("---", 2)
        if len(parts) < 3:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="Invalid YAML frontmatter structure (missing closing ---)",
                    file=str(skill_md),
                )
            )
            return issues

        try:
            if HAS_PYYAML:
                yaml.safe_load(parts[1])
            else:
                parse_yaml(parts[1])
        except Exception as e:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"YAML parsing error: {e}",
                    file=str(skill_md),
                )
            )

        return issues


class RequiredFieldsChecker(BaseChecker):
    """Validates required YAML fields are present."""

    name = "required-fields"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []  # YamlChecker will report this

        issues = []
        for fld in REQUIRED_FIELDS:
            if fld not in result.frontmatter:
                issues.append(
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Missing required field: {fld}",
                        file=str(result.skill_md),
                    )
                )

        return issues


class NameFormatChecker(BaseChecker):
    """Validates skill name format (letters, numbers, hyphens only)."""

    name = "name-format"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        issues = []
        name = result.frontmatter.get("name", "")
        if name and not NAME_PATTERN.match(name):
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"Invalid name format: '{name}'. Use only letters, numbers, and hyphens.",
                    file=str(result.skill_md),
                )
            )

        # Also check that directory name matches skill name
        if name and name != skill_name:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Directory name '{skill_name}' doesn't match skill name '{name}'",
                    file=str(result.skill_md),
                )
            )

        return issues


class DescriptionLengthChecker(BaseChecker):
    """Validates description is within max length."""

    name = "description-length"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        issues = []
        description = result.frontmatter.get("description", "")
        if description and len(description) > MAX_DESCRIPTION_LENGTH:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Description exceeds {MAX_DESCRIPTION_LENGTH} chars ({len(description)} chars)",
                    file=str(result.skill_md),
                )
            )

        return issues


class DescriptionFormatChecker(BaseChecker):
    """Validates description contains a 'Use when' trigger clause."""

    name = "description-format"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        issues = []
        description = result.frontmatter.get("description", "")
        if description and DESCRIPTION_TRIGGER not in description:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Description should contain a '{DESCRIPTION_TRIGGER}' trigger clause (no process steps)",
                    file=str(result.skill_md),
                )
            )

        return issues


class MetadataFieldsChecker(BaseChecker):
    """Validates metadata key exists with required sub-fields."""

    name = "metadata-fields"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        issues = []
        metadata = result.frontmatter.get("metadata")
        if metadata is None:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="Missing 'metadata' key",
                    file=str(result.skill_md),
                )
            )
            return issues

        if not isinstance(metadata, dict):
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="'metadata' must be a mapping",
                    file=str(result.skill_md),
                )
            )
            return issues

        for fld in REQUIRED_METADATA_FIELDS:
            if fld not in metadata:
                issues.append(
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Missing required metadata field: {fld}",
                        file=str(result.skill_md),
                    )
                )

        # Validate triggers is a non-empty string
        triggers = metadata.get("triggers")
        if triggers is not None and (not isinstance(triggers, str) or not triggers.strip()):
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="'metadata.triggers' must be a non-empty string",
                    file=str(result.skill_md),
                )
            )

        # Validate domain is a known value (warning, not error)
        domain = metadata.get("domain")
        if domain is not None and domain not in KNOWN_DOMAINS:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Unknown domain: '{domain}'. Known: {', '.join(sorted(KNOWN_DOMAINS))}",
                    file=str(result.skill_md),
                )
            )

        # Validate related-skills is a non-empty string with valid skill references
        if "related-skills" in metadata:
            related = metadata.get("related-skills")
            if related is None or (isinstance(related, str) and not related.strip()):
                issues.append(
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.WARNING,
                        message="'metadata.related-skills' is empty",
                        file=str(result.skill_md),
                    )
                )
            elif not isinstance(related, str):
                issues.append(
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.ERROR,
                        message="'metadata.related-skills' must be a string",
                        file=str(result.skill_md),
                    )
                )
            else:
                # Validate each comma-separated value resolves to an existing skill directory
                skills_dir = skill_path.parent
                for ref in (r.strip() for r in related.split(",")):
                    if ref and not (skills_dir / ref).is_dir():
                        issues.append(
                            ValidationIssue(
                                skill=skill_name,
                                check=self.name,
                                severity=Severity.WARNING,
                                message=f"'metadata.related-skills' references non-existent skill: '{ref}'",
                                file=str(result.skill_md),
                            )
                        )

        return issues


class ReferencesDirectoryChecker(BaseChecker):
    """Validates references/ directory exists."""

    name = "references-directory"
    category = "references"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        issues = []
        refs_dir = skill_path / "references"

        if not refs_dir.exists():
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="Missing references/ directory",
                    file=str(refs_dir),
                )
            )
        elif not refs_dir.is_dir():
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="'references' exists but is not a directory",
                    file=str(refs_dir),
                )
            )

        return issues


class ReferenceFileCountChecker(BaseChecker):
    """Validates skill has at least 1 reference file."""

    name = "reference-file-count"
    category = "references"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        issues = []
        refs_dir = skill_path / "references"

        if not refs_dir.exists() or not refs_dir.is_dir():
            return issues  # ReferencesDirectoryChecker will report this

        ref_files = list(refs_dir.glob("*.md"))
        if len(ref_files) == 0:
            issues.append(
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message="No reference files found in references/",
                    file=str(refs_dir),
                )
            )

        return issues


class NonStandardHeadersChecker(BaseChecker):
    """Reports reference files with non-standard headers that should be removed.

    The official Agent Skills spec (https://agentskills.io/specification) does not
    require any specific headers in reference files. This checker identifies files
    that have the old project-specific 'Reference for:' and 'Load when:' headers
    so they can be cleaned up.
    """

    name = "non-standard-headers"
    category = "references"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        issues = []
        refs_dir = skill_path / "references"

        if not refs_dir.exists() or not refs_dir.is_dir():
            return issues

        ref_files = list(refs_dir.glob("*.md"))
        for ref_file in ref_files:
            content = ref_file.read_text()
            lines = content.split("\n")[:10]  # Check first 10 lines
            header_text = "\n".join(lines)

            has_ref_for = "Reference for:" in header_text
            has_load_when = "Load when:" in header_text

            if has_ref_for or has_load_when:
                headers_found = []
                if has_ref_for:
                    headers_found.append("'Reference for:'")
                if has_load_when:
                    headers_found.append("'Load when:'")
                issues.append(
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Has non-standard headers ({', '.join(headers_found)}) - must be removed",
                        file=str(ref_file),
                    )
                )

        return issues


class MetadataEnumChecker(BaseChecker):
    """Generic checker for metadata enum fields."""

    field_name: str = ""  # e.g., "scope"
    valid_values: frozenset[str] = frozenset()

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        metadata = result.frontmatter.get("metadata", {})
        if not isinstance(metadata, dict):
            return []

        value = metadata.get(self.field_name)
        if value is not None and value not in self.valid_values:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Unknown {self.field_name}: '{value}'. Expected: {', '.join(sorted(self.valid_values))}",
                    file=str(result.skill_md),
                )
            ]
        return []


class ScopeEnumChecker(MetadataEnumChecker):
    """Validates metadata.scope uses a known value."""

    name = "scope-enum"
    category = "yaml"
    field_name = "scope"
    valid_values = frozenset(VALID_SCOPES)


class OutputFormatEnumChecker(MetadataEnumChecker):
    """Validates metadata.output-format uses a known value."""

    name = "output-format-enum"
    category = "yaml"
    field_name = "output-format"
    valid_values = frozenset(VALID_OUTPUT_FORMATS)


class CoreWorkflowStepCountChecker(BaseChecker):
    """Validates Core Workflow section has exactly 5 numbered steps."""

    name = "core-workflow-steps"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        # Find Core Workflow section
        workflow_match = CORE_WORKFLOW_PATTERN.search(result.body)
        if not workflow_match:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message="Missing '## Core Workflow' section",
                    file=str(result.skill_md),
                )
            ]

        # Get section content (up to next H2 or end of file)
        section_start = workflow_match.end()
        next_section = NEXT_SECTION_PATTERN.search(result.body[section_start:])
        if next_section:
            section_content = result.body[section_start : section_start + next_section.start()]
        else:
            section_content = result.body[section_start:]

        # Count numbered list items (e.g., "1. ", "2. ", etc.)
        steps = NUMBERED_STEP_PATTERN.findall(section_content)
        step_count = len(steps)

        if step_count != 5:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Core Workflow has {step_count} steps (expected 5)",
                    file=str(result.skill_md),
                )
            ]

        return []


class WhenToUseFormatChecker(BaseChecker):
    """Validates 'When to Use' section uses bullet list format."""

    name = "when-to-use-format"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        # Find "When to Use" section (various forms)
        section_match = WHEN_TO_USE_PATTERN.search(result.body)
        if not section_match:
            # Section is optional; don't warn if missing
            return []

        # Get section content (up to next H2 or end of file)
        section_start = section_match.end()
        next_section = NEXT_SECTION_PATTERN.search(result.body[section_start:])
        if next_section:
            section_content = result.body[section_start : section_start + next_section.start()]
        else:
            section_content = result.body[section_start:]

        # Check for bullet list format
        lines = section_content.strip().split("\n")
        content_lines = [line for line in lines if line.strip() and not line.strip().startswith("#")]

        if not content_lines:
            return []

        # Check if lines use bullet format (- or *)
        non_bullet_lines = [line for line in content_lines if line.strip() and not BULLET_PATTERN.match(line)]

        # Allow some non-bullet lines (like sub-items or code blocks), but warn if majority is prose
        if len(non_bullet_lines) > len(content_lines) // 2:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message="'When to Use' section should use bullet list format (- or *)",
                    file=str(result.skill_md),
                )
            ]

        return []


class SectionOrderChecker(BaseChecker):
    """Validates H2 sections appear in canonical order."""

    name = "section-order"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        # Extract all H2 headers
        headers = H2_HEADER_PATTERN.findall(result.body)

        # Filter to only canonical sections (ignore non-standard headers)
        canonical_set = set(CANONICAL_SECTIONS)
        found_canonical = [h.strip() for h in headers if h.strip() in canonical_set]

        if len(found_canonical) < 2:
            # Not enough canonical sections to check order
            return []

        # Check order: for each pair of canonical sections found, verify order
        for i, section in enumerate(found_canonical[:-1]):
            current_idx = CANONICAL_SECTIONS.index(section)
            next_idx = CANONICAL_SECTIONS.index(found_canonical[i + 1])
            if current_idx > next_idx:
                return [
                    ValidationIssue(
                        skill=skill_name,
                        check=self.name,
                        severity=Severity.WARNING,
                        message=f"Section order: '{section}' should come after '{found_canonical[i + 1]}'",
                        file=str(result.skill_md),
                    )
                ]

        return []


class LineCountChecker(BaseChecker):
    """Validates SKILL.md has 80-100 non-blank lines (excluding frontmatter)."""

    name = "line-count"
    category = "yaml"

    def check(self, skill_path: Path, skill_name: str) -> list[ValidationIssue]:
        result = self._extract_frontmatter(skill_path)
        if result is None:
            return []

        # Count non-blank lines
        non_blank_lines = [line for line in result.body.split("\n") if line.strip()]
        count = len(non_blank_lines)

        if count < MIN_NON_BLANK_LINES:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"SKILL.md has {count} non-blank lines (minimum {MIN_NON_BLANK_LINES})",
                    file=str(result.skill_md),
                )
            ]
        elif count > MAX_NON_BLANK_LINES:
            return [
                ValidationIssue(
                    skill=skill_name,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"SKILL.md has {count} non-blank lines (maximum {MAX_NON_BLANK_LINES})",
                    file=str(result.skill_md),
                )
            ]

        return []


# =============================================================================
# Workflow Checkers
# =============================================================================


class WorkflowDefinitionChecker:
    """Validates per-command YAML definition files against the schema."""

    name = "workflow-definition"

    def check(self, base_path: Path) -> list[ValidationIssue]:
        issues = []
        commands_dir = base_path / COMMANDS_DIR_WORKFLOW

        if not commands_dir.exists():
            issues.append(
                ValidationIssue(
                    skill="__workflow__",
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"Commands directory not found: {commands_dir}",
                )
            )
            return issues

        yaml_files = sorted([f for f in commands_dir.rglob("*.yaml") if f.name != "workflow-manifest.yaml"])

        if not yaml_files:
            issues.append(
                ValidationIssue(
                    skill="__workflow__",
                    check=self.name,
                    severity=Severity.WARNING,
                    message="No workflow definition YAML files found",
                )
            )
            return issues

        for yaml_file in yaml_files:
            rel_path = str(yaml_file.relative_to(base_path))
            issues.extend(self._validate_definition(yaml_file, rel_path, base_path))

        return issues

    def _validate_definition(self, yaml_file: Path, rel_path: str, base_path: Path) -> list[ValidationIssue]:
        issues = []

        try:
            data = parse_yaml(yaml_file.read_text())
            if data is None:
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.ERROR,
                        message="YAML file is empty",
                        file=rel_path,
                    )
                )
                return issues
        except Exception as e:
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"YAML parse error: {e}",
                    file=rel_path,
                )
            )
            return issues

        command = data.get("command", "")
        is_utility = ":" not in command

        # Check required fields (phase is optional for utility commands)
        for req_field in REQUIRED_DEFINITION_FIELDS:
            if req_field not in data:
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Missing required field: {req_field}",
                        file=rel_path,
                    )
                )

        if not is_utility and "phase" not in data:
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.ERROR,
                    message="Missing required field: phase (required for phased commands)",
                    file=rel_path,
                )
            )

        # Validate phase value
        phase = data.get("phase", "")
        if phase and phase not in VALID_PHASES:
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Non-standard phase: '{phase}'. Expected: {', '.join(sorted(VALID_PHASES))}",
                    file=rel_path,
                )
            )

        # Validate command prefix matches phase
        if command and phase and ":" in command:
            cmd_phase = command.split(":")[0]
            # Allow plural forms (e.g., "retrospectives" phase, "retrospectives:" prefix)
            if cmd_phase != phase and cmd_phase.rstrip("s") != phase.rstrip("s"):
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.WARNING,
                        message=f"Command prefix '{cmd_phase}' doesn't match phase '{phase}'",
                        file=rel_path,
                    )
                )

        # Validate status
        status = data.get("status", "existing")
        if status not in VALID_STATUS:
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.WARNING,
                    message=f"Unknown status: '{status}'. Expected: {', '.join(sorted(VALID_STATUS))}",
                    file=rel_path,
                )
            )

        # Validate path resolves (when status is existing)
        cmd_path = data.get("path", "")
        if cmd_path and status == "existing" and not (base_path / cmd_path).exists():
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"Command file not found: {cmd_path}",
                    file=rel_path,
                )
            )

        # Validate description path resolves
        desc_path = data.get("description", "")
        if desc_path and not (base_path / desc_path).exists():
            issues.append(
                ValidationIssue(
                    skill=rel_path,
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"Description file not found: {desc_path}",
                    file=rel_path,
                )
            )

        # Validate requires values
        requires = data.get("requires", [])
        if isinstance(requires, list):
            for req in requires:
                if req not in VALID_REQUIRES:
                    issues.append(
                        ValidationIssue(
                            skill=rel_path,
                            check=self.name,
                            severity=Severity.WARNING,
                            message=f"Unknown requires value: '{req}'. Expected: {', '.join(sorted(VALID_REQUIRES))}",
                            file=rel_path,
                        )
                    )

        # Validate inputs structure
        inputs = data.get("inputs", [])
        if isinstance(inputs, list):
            for i, inp in enumerate(inputs):
                if isinstance(inp, dict):
                    for req_field in ["name", "type", "required", "description"]:
                        if req_field not in inp:
                            issues.append(
                                ValidationIssue(
                                    skill=rel_path,
                                    check=self.name,
                                    severity=Severity.ERROR,
                                    message=f"Input [{i}] missing field: {req_field}",
                                    file=rel_path,
                                )
                            )
                    inp_type = inp.get("type", "")
                    if inp_type and inp_type not in VALID_INPUT_TYPES:
                        issues.append(
                            ValidationIssue(
                                skill=rel_path,
                                check=self.name,
                                severity=Severity.WARNING,
                                message=f"Input [{i}] unknown type: '{inp_type}'",
                                file=rel_path,
                            )
                        )

        # Validate outputs structure
        outputs = data.get("outputs", [])
        if isinstance(outputs, list):
            for i, out in enumerate(outputs):
                if isinstance(out, dict):
                    for req_field in ["name", "type"]:
                        if req_field not in out:
                            issues.append(
                                ValidationIssue(
                                    skill=rel_path,
                                    check=self.name,
                                    severity=Severity.ERROR,
                                    message=f"Output [{i}] missing field: {req_field}",
                                    file=rel_path,
                                )
                            )
                    out_type = out.get("type", "")
                    if out_type and out_type not in VALID_OUTPUT_TYPES:
                        issues.append(
                            ValidationIssue(
                                skill=rel_path,
                                check=self.name,
                                severity=Severity.WARNING,
                                message=f"Output [{i}] unknown type: '{out_type}'",
                                file=rel_path,
                            )
                        )

        return issues


class ManifestDagChecker:
    """Validates workflow-manifest.yaml structure and DAG integrity."""

    name = "manifest-dag"

    def check(self, base_path: Path) -> list[ValidationIssue]:
        issues = []
        manifest_path = base_path / MANIFEST_FILE

        if not manifest_path.exists():
            issues.append(
                ValidationIssue(
                    skill="__manifest__",
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"Manifest file not found: {MANIFEST_FILE}",
                )
            )
            return issues

        try:
            data = parse_yaml(manifest_path.read_text())
            if data is None:
                issues.append(
                    ValidationIssue(
                        skill="__manifest__",
                        check=self.name,
                        severity=Severity.ERROR,
                        message="Manifest is empty",
                        file=str(manifest_path),
                    )
                )
                return issues
        except Exception as e:
            issues.append(
                ValidationIssue(
                    skill="__manifest__",
                    check=self.name,
                    severity=Severity.ERROR,
                    message=f"YAML parse error: {e}",
                    file=str(manifest_path),
                )
            )
            return issues

        phases = data.get("phases", {})
        if not phases:
            issues.append(
                ValidationIssue(
                    skill="__manifest__",
                    check=self.name,
                    severity=Severity.ERROR,
                    message="No phases defined in manifest",
                    file=str(manifest_path),
                )
            )
            return issues

        phase_names = set(phases.keys())
        all_commands = set()

        for phase_name, phase_data in phases.items():
            if not isinstance(phase_data, dict):
                issues.append(
                    ValidationIssue(
                        skill="__manifest__",
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Phase '{phase_name}' must be a mapping",
                        file=str(manifest_path),
                    )
                )
                continue

            # Check description path
            desc = phase_data.get("description", "")
            if desc and not (base_path / desc).exists():
                issues.append(
                    ValidationIssue(
                        skill="__manifest__",
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Phase '{phase_name}' description not found: {desc}",
                        file=str(manifest_path),
                    )
                )

            # Check dependency references
            depends_on = phase_data.get("depends_on", [])
            if isinstance(depends_on, list):
                for dep in depends_on:
                    if isinstance(dep, dict):
                        dep_phase = dep.get("phase", "")
                        if dep_phase and dep_phase not in phase_names:
                            issues.append(
                                ValidationIssue(
                                    skill="__manifest__",
                                    check=self.name,
                                    severity=Severity.ERROR,
                                    message=f"Phase '{phase_name}' depends on undefined phase: '{dep_phase}'",
                                    file=str(manifest_path),
                                )
                            )
                        strength = dep.get("strength", "")
                        if strength and strength not in VALID_DEPENDENCY_STRENGTHS:
                            issues.append(
                                ValidationIssue(
                                    skill="__manifest__",
                                    check=self.name,
                                    severity=Severity.WARNING,
                                    message=f"Phase '{phase_name}' dependency strength '{strength}' not standard",
                                    file=str(manifest_path),
                                )
                            )

            # Check commands
            commands = phase_data.get("commands", [])
            if isinstance(commands, list):
                for cmd in commands:
                    if isinstance(cmd, dict):
                        cmd_name = cmd.get("command", "")
                        if cmd_name:
                            if cmd_name in all_commands:
                                issues.append(
                                    ValidationIssue(
                                        skill="__manifest__",
                                        check=self.name,
                                        severity=Severity.ERROR,
                                        message=f"Duplicate command: '{cmd_name}'",
                                        file=str(manifest_path),
                                    )
                                )
                            all_commands.add(cmd_name)

                        definition = cmd.get("definition", "")
                        if definition and not (base_path / definition).exists():
                            issues.append(
                                ValidationIssue(
                                    skill="__manifest__",
                                    check=self.name,
                                    severity=Severity.ERROR,
                                    message=f"Command '{cmd_name}' definition not found: {definition}",
                                    file=str(manifest_path),
                                )
                            )

        # Check utilities
        utilities = data.get("utilities", [])
        if isinstance(utilities, list):
            for util in utilities:
                if isinstance(util, dict):
                    cmd_name = util.get("command", "")
                    if cmd_name:
                        if cmd_name in all_commands:
                            issues.append(
                                ValidationIssue(
                                    skill="__manifest__",
                                    check=self.name,
                                    severity=Severity.ERROR,
                                    message=f"Duplicate command: '{cmd_name}'",
                                    file=str(manifest_path),
                                )
                            )
                        all_commands.add(cmd_name)

                    definition = util.get("definition", "")
                    if definition and not (base_path / definition).exists():
                        issues.append(
                            ValidationIssue(
                                skill="__manifest__",
                                check=self.name,
                                severity=Severity.ERROR,
                                message=f"Utility '{cmd_name}' definition not found: {definition}",
                                file=str(manifest_path),
                            )
                        )

        # DAG cycle detection
        issues.extend(self._detect_cycles(phases, manifest_path))

        # Cross-check: manifest commands match their definition files
        issues.extend(self._check_definition_consistency(phases, utilities, base_path, manifest_path))

        return issues

    def _detect_cycles(self, phases: dict, manifest_path: Path) -> list[ValidationIssue]:
        """Detect cycles in the phase dependency graph using DFS."""
        issues = []

        # Build adjacency list
        graph = {}
        for phase_name, phase_data in phases.items():
            if not isinstance(phase_data, dict):
                continue
            deps = []
            for dep in phase_data.get("depends_on", []):
                if isinstance(dep, dict):
                    dep_phase = dep.get("phase", "")
                    if dep_phase:
                        deps.append(dep_phase)
            graph[phase_name] = deps

        color = {node: DFSColor.WHITE for node in graph}
        path: list[str] = []

        def dfs(node: str) -> None:
            color[node] = DFSColor.GRAY
            path.append(node)
            for neighbor in graph.get(node, []):
                if neighbor not in color:
                    continue  # Reference to undefined phase (caught elsewhere)
                if color[neighbor] == DFSColor.GRAY:
                    cycle_start = path.index(neighbor)
                    cycle = [*path[cycle_start:], neighbor]
                    issues.append(
                        ValidationIssue(
                            skill="__manifest__",
                            check=self.name,
                            severity=Severity.ERROR,
                            message=f"DAG cycle detected: {' -> '.join(cycle)}",
                            file=str(manifest_path),
                        )
                    )
                    path.pop()
                    return
                if color[neighbor] == DFSColor.WHITE:
                    dfs(neighbor)
            path.pop()
            color[node] = DFSColor.BLACK

        for node in graph:
            if color[node] == DFSColor.WHITE:
                dfs(node)

        return issues

    def _check_definition_consistency(
        self,
        phases: dict,
        utilities: list,
        base_path: Path,
        manifest_path: Path,
    ) -> list[ValidationIssue]:
        """Verify manifest command names match the command field in their YAML definitions."""
        issues = []

        def check_cmd(cmd_name: str, definition_path: str):
            full_path = base_path / definition_path
            if not full_path.exists():
                return  # Missing file already reported
            try:
                data = parse_yaml(full_path.read_text())
                if data and data.get("command", "") != cmd_name:
                    issues.append(
                        ValidationIssue(
                            skill="__manifest__",
                            check=self.name,
                            severity=Severity.ERROR,
                            message=(
                                f"Manifest command '{cmd_name}' doesn't match "
                                f"definition command '{data.get('command', '')}' in {definition_path}"
                            ),
                            file=str(manifest_path),
                        )
                    )
            except Exception:
                pass  # Parse errors reported elsewhere

        for phase_data in phases.values():
            if not isinstance(phase_data, dict):
                continue
            for cmd in phase_data.get("commands", []):
                if isinstance(cmd, dict):
                    check_cmd(cmd.get("command", ""), cmd.get("definition", ""))

        if isinstance(utilities, list):
            for util in utilities:
                if isinstance(util, dict):
                    check_cmd(util.get("command", ""), util.get("definition", ""))

        return issues


class CommandFrontmatterChecker:
    """Strict-parses YAML frontmatter on every .md file under commands/.

    The lenient simple_yaml_parse fallback would mask real bugs (e.g. unquoted
    inner double quotes, leading '[', unquoted ': ') that strict parsers in
    downstream tools (oh-my-pi, GitHub renderer) reject. Always uses PyYAML
    here regardless of HAS_PYYAML; emits one warning if PyYAML is missing.
    """

    name = "command-frontmatter"

    def check(self, base_path: Path) -> list[ValidationIssue]:
        issues: list[ValidationIssue] = []
        commands_dir = base_path / COMMANDS_DIR_WORKFLOW

        if not commands_dir.exists():
            return issues

        if not HAS_PYYAML:
            issues.append(
                ValidationIssue(
                    skill="__command-frontmatter__",
                    check=self.name,
                    severity=Severity.WARNING,
                    message="PyYAML not installed; skipping strict frontmatter parse of command files",
                )
            )
            return issues

        for md_file in sorted(commands_dir.rglob("*.md")):
            if "references" in md_file.parts:
                continue

            rel_path = str(md_file.relative_to(base_path))
            try:
                content = md_file.read_text()
            except OSError as e:
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"Could not read file: {e}",
                        file=rel_path,
                    )
                )
                continue

            if not content.startswith("---"):
                continue  # No frontmatter; slash commands without it are valid

            parts = content.split("---", 2)
            if len(parts) < 3:
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.ERROR,
                        message="Frontmatter opened with --- but never closed",
                        file=rel_path,
                    )
                )
                continue

            try:
                yaml.safe_load(parts[1])
            except yaml.YAMLError as e:
                first_line = str(e).split("\n")[0]
                issues.append(
                    ValidationIssue(
                        skill=rel_path,
                        check=self.name,
                        severity=Severity.ERROR,
                        message=f"YAML frontmatter parse error: {first_line}",
                        file=rel_path,
                    )
                )

        return issues


class WorkflowOrphanChecker:
    """Detects command .md files not referenced by any YAML definition."""

    name = "workflow-orphans"

    def check(self, base_path: Path) -> list[ValidationIssue]:
        issues = []
        commands_dir = base_path / COMMANDS_DIR_WORKFLOW

        if not commands_dir.exists():
            return issues

        # Collect path references from all per-command YAML files
        referenced_paths = set()
        for yaml_file in commands_dir.rglob("*.yaml"):
            if yaml_file.name == "workflow-manifest.yaml":
                continue
            try:
                data = parse_yaml(yaml_file.read_text())
                if data and "path" in data:
                    referenced_paths.add(data["path"])
            except Exception:
                pass  # Parse errors reported by WorkflowDefinitionChecker

        # Collect command .md files (exclude references/, skip COMMAND.md pattern)
        for md_file in sorted(commands_dir.rglob("*.md")):
            if "references" in md_file.parts:
                continue
            if md_file.name == "COMMAND.md":
                continue

            md_rel = str(md_file.relative_to(base_path))
            if md_rel not in referenced_paths:
                issues.append(
                    ValidationIssue(
                        skill="__orphans__",
                        check=self.name,
                        severity=Severity.WARNING,
                        message=f"Command file has no YAML definition: {md_rel}",
                        file=md_rel,
                    )
                )

        return issues


# =============================================================================
# Count Consistency Checker
# =============================================================================


class CountConsistencyChecker:
    """Validates count consistency across documentation files."""

    def check(self, skills_dir: Path) -> list[ValidationIssue]:
        issues = []
        base_path = skills_dir.parent

        # Count actual skills
        skill_count = sum(1 for d in skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists())

        # Count actual reference files
        ref_count = sum(1 for ref in skills_dir.rglob("references/*.md"))

        # Check each file for count mentions
        for file_path in COUNT_FILES:
            full_path = base_path / file_path
            if not full_path.exists():
                continue

            content = full_path.read_text()

            # Check for skill count mentions
            skill_patterns = [
                r"(\d+)\s*(?:specialized\s+)?skills",
                r"(\d+)\s*Skills",
            ]

            for pattern in skill_patterns:
                matches = re.findall(pattern, content, re.IGNORECASE)
                for match in matches:
                    found_count = int(match)
                    if found_count != skill_count:
                        issues.append(
                            ValidationIssue(
                                skill="__counts__",
                                check="count-consistency",
                                severity=Severity.WARNING,
                                message=f"Skill count mismatch: file says {found_count}, actual is {skill_count}",
                                file=str(full_path),
                            )
                        )
                        break  # Only report once per file

            # Check for reference file count mentions
            ref_patterns = [
                r"(\d+)\s*[Rr]eference\s*[Ff]iles",
            ]

            for pattern in ref_patterns:
                matches = re.findall(pattern, content)
                for match in matches:
                    found_count = int(match)
                    if found_count != ref_count:
                        issues.append(
                            ValidationIssue(
                                skill="__counts__",
                                check="count-consistency",
                                severity=Severity.WARNING,
                                message=f"Reference count mismatch: file says {found_count}, actual is {ref_count}",
                                file=str(full_path),
                            )
                        )
                        break

        return issues


# =============================================================================
# Cross-Reference Checker
# =============================================================================


class CrossRefChecker:
    """Validates bidirectional related-skills references and detects orphan skills."""

    name = "crossrefs"

    def check(self, skills_dir: Path) -> list[ValidationIssue]:
        issues = []
        graph = self._build_graph(skills_dir)
        issues.extend(self._check_bidirectional(graph))
        issues.extend(self._check_orphans(graph, skills_dir))
        return issues

    def _build_graph(self, skills_dir: Path) -> dict[str, set[str]]:
        """Parse all skills' metadata.related-skills into a graph."""
        graph: dict[str, set[str]] = {}

        for skill_path in sorted(skills_dir.iterdir()):
            if not skill_path.is_dir() or skill_path.name.startswith("."):
                continue

            result = BaseChecker._extract_frontmatter(skill_path)
            if result is None:
                if (skill_path / "SKILL.md").exists():
                    graph[skill_path.name] = set()
                continue

            metadata = result.frontmatter.get("metadata", {})
            if not isinstance(metadata, dict):
                graph[skill_path.name] = set()
                continue

            related = metadata.get("related-skills", "")
            if isinstance(related, str) and related.strip():
                refs = {r.strip() for r in related.split(",") if r.strip()}
            else:
                refs = set()

            graph[skill_path.name] = refs

        return graph

    def _check_bidirectional(self, graph: dict[str, set[str]]) -> list[ValidationIssue]:
        """Warn when A references B but B does not reference A."""
        issues = []
        reported: set[tuple[str, str]] = set()

        for skill, refs in sorted(graph.items()):
            for ref in sorted(refs):
                if ref not in graph:
                    continue  # Non-existent skill; MetadataFieldsChecker handles this
                pair = (min(skill, ref), max(skill, ref))
                if pair in reported:
                    continue
                if skill not in graph[ref]:
                    reported.add(pair)
                    issues.append(
                        ValidationIssue(
                            skill="__crossrefs__",
                            check=self.name,
                            severity=Severity.WARNING,
                            message=f"'{skill}' references '{ref}' but '{ref}' does not reference '{skill}'",
                        )
                    )

        return issues

    def _check_orphans(self, graph: dict[str, set[str]], skills_dir: Path) -> list[ValidationIssue]:
        """Warn about skills with no incoming or outgoing references."""
        issues = []

        # Collect all skills that are referenced by at least one other skill
        referenced: set[str] = set()
        for refs in graph.values():
            referenced.update(refs)

        for skill, refs in sorted(graph.items()):
            if not refs and skill not in referenced:
                issues.append(
                    ValidationIssue(
                        skill="__crossrefs__",
                        check=self.name,
                        severity=Severity.WARNING,
                        message=f"Orphan skill: '{skill}' has no related-skills and is not referenced by any other skill",
                    )
                )

        return issues


# =============================================================================
# Workflow Validator
# =============================================================================


class WorkflowValidator:
    """Orchestrates workflow definition and manifest validation."""

    def __init__(self, base_path: Path):
        self.base_path = base_path
        self.checkers = [
            WorkflowDefinitionChecker(),
            ManifestDagChecker(),
            CommandFrontmatterChecker(),
            WorkflowOrphanChecker(),
        ]

    def validate(self) -> list[ValidationIssue]:
        issues = []
        for checker in self.checkers:
            issues.extend(checker.check(self.base_path))
        return issues


# =============================================================================
# Formatters
# =============================================================================


class TableFormatter:
    """Human-readable table output."""

    def format(self, report: ValidationReport) -> str:
        lines = []

        # Header
        lines.append("=" * 80)
        lines.append("SKILL VALIDATION REPORT")
        lines.append("=" * 80)
        lines.append("")

        # Skill issues
        skills_with_issues = [r for r in report.results if r.issues]
        if skills_with_issues:
            lines.append("SKILL ISSUES:")
            lines.append("-" * 80)
            for result in skills_with_issues:
                lines.append(f"\n  {result.skill}:")
                for issue in result.issues:
                    icon = "ERROR" if issue.severity == Severity.ERROR else "WARN "
                    file_info = f" ({issue.file})" if issue.file else ""
                    lines.append(f"    [{icon}] {issue.check}: {issue.message}{file_info}")

        # Workflow issues
        if report.workflow_issues:
            lines.append("")
            lines.append("WORKFLOW ISSUES:")
            lines.append("-" * 80)
            for issue in report.workflow_issues:
                icon = "ERROR" if issue.severity == Severity.ERROR else "WARN "
                file_info = f" ({issue.file})" if issue.file else ""
                scope = f"[{issue.skill}] " if issue.skill else ""
                lines.append(f"  [{icon}] {scope}{issue.check}: {issue.message}{file_info}")

        # Cross-reference issues
        if report.crossref_issues:
            lines.append("")
            lines.append("CROSS-REFERENCE ISSUES:")
            lines.append("-" * 80)
            for issue in report.crossref_issues:
                icon = "ERROR" if issue.severity == Severity.ERROR else "WARN "
                lines.append(f"  [{icon}] {issue.message}")

        # Count issues
        if report.count_issues:
            lines.append("")
            lines.append("COUNT CONSISTENCY ISSUES:")
            lines.append("-" * 80)
            for issue in report.count_issues:
                icon = "ERROR" if issue.severity == Severity.ERROR else "WARN "
                file_info = f" ({issue.file})" if issue.file else ""
                lines.append(f"  [{icon}] {issue.message}{file_info}")

        # Summary
        lines.append("")
        lines.append("=" * 80)
        lines.append("SUMMARY")
        lines.append("=" * 80)
        lines.append(f"  Skills validated: {len(report.results)}")
        lines.append(f"  Total errors:     {report.total_errors}")
        lines.append(f"  Total warnings:   {report.total_warnings}")
        lines.append("")

        if report.has_errors:
            lines.append("  STATUS: FAILED (errors found)")
        elif report.total_warnings > 0:
            lines.append("  STATUS: PASSED (with warnings)")
        else:
            lines.append("  STATUS: PASSED")

        lines.append("")
        return "\n".join(lines)


class JsonFormatter:
    """Machine-readable JSON output."""

    def format(self, report: ValidationReport) -> str:
        return json.dumps(report.to_dict(), indent=2)


# =============================================================================
# Skill Validator
# =============================================================================


class SkillValidator:
    """Main validator that orchestrates skill checks."""

    def __init__(
        self,
        skills_dir: str = SKILLS_DIR,
        check_category: str | None = None,
        skill_filter: str | None = None,
    ):
        self.skills_dir = Path(skills_dir)
        self.check_category = check_category
        self.skill_filter = skill_filter

        # Register all checkers
        all_checkers = [
            YamlChecker(),
            RequiredFieldsChecker(),
            MetadataFieldsChecker(),
            NameFormatChecker(),
            DescriptionLengthChecker(),
            DescriptionFormatChecker(),
            ScopeEnumChecker(),
            OutputFormatEnumChecker(),
            CoreWorkflowStepCountChecker(),
            WhenToUseFormatChecker(),
            SectionOrderChecker(),
            LineCountChecker(),
            ReferencesDirectoryChecker(),
            ReferenceFileCountChecker(),
            NonStandardHeadersChecker(),
        ]

        # Filter by category if specified
        if check_category:
            self.checkers = [c for c in all_checkers if c.category == check_category]
        else:
            self.checkers = all_checkers

        self.count_checker = CountConsistencyChecker()

    def validate(self) -> ValidationReport:
        """Run all validations and return report."""
        report = ValidationReport()

        # Find all skill directories
        if not self.skills_dir.exists():
            print(f"Error: Skills directory not found: {self.skills_dir}")
            sys.exit(1)

        skill_dirs = sorted([d for d in self.skills_dir.iterdir() if d.is_dir() and not d.name.startswith(".")])

        # Filter to specific skill if requested
        if self.skill_filter:
            skill_dirs = [d for d in skill_dirs if d.name == self.skill_filter]
            if not skill_dirs:
                print(f"Error: Skill not found: {self.skill_filter}")
                sys.exit(1)

        # Run checks on each skill
        for skill_dir in skill_dirs:
            result = ValidationResult(skill=skill_dir.name)
            for checker in self.checkers:
                issues = checker.check(skill_dir, skill_dir.name)
                result.issues.extend(issues)
            report.results.append(result)

        # Run count consistency check (unless filtering to single skill or category)
        if not self.skill_filter and not self.check_category:
            report.count_issues = self.count_checker.check(self.skills_dir)

        return report


# =============================================================================
# CLI
# =============================================================================


def main():
    parser = argparse.ArgumentParser(
        description="Validate skill structure and consistency for Claude Skills repository.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python scripts/validate-skills.py              # Run all checks
  python scripts/validate-skills.py --check yaml # YAML-related checks only
  python scripts/validate-skills.py --check references  # Reference checks only
  python scripts/validate-skills.py --check workflows   # Workflow definition checks only
  python scripts/validate-skills.py --skill react-expert  # Single skill
  python scripts/validate-skills.py --format json  # JSON for CI

Check categories:
  yaml        - YAML frontmatter validation (parsing, required fields, format)
  references  - Reference file validation (directory, count, headers)
  workflows   - Workflow YAML definitions, manifest DAG, orphan detection
  crossrefs   - Bidirectional related-skills validation, orphan detection
""",
    )

    parser.add_argument(
        "--check",
        choices=["yaml", "references", "workflows", "crossrefs"],
        help="Run only checks in the specified category",
    )

    parser.add_argument(
        "--skill",
        help="Validate only the specified skill",
    )

    parser.add_argument(
        "--format",
        choices=["table", "json"],
        default="table",
        help="Output format (default: table)",
    )

    parser.add_argument(
        "--skills-dir",
        default=SKILLS_DIR,
        help=f"Path to skills directory (default: {SKILLS_DIR})",
    )

    args = parser.parse_args()

    report = ValidationReport()

    # Run skill validation (unless --check workflows or --check crossrefs)
    if args.check not in ("workflows", "crossrefs"):
        validator = SkillValidator(
            skills_dir=args.skills_dir,
            check_category=args.check,
            skill_filter=args.skill,
        )
        report = validator.validate()

    # Run workflow validation (unless filtering to skill-specific checks)
    if args.check == "workflows" or (args.check is None and not args.skill):
        base_path = Path(".")
        workflow_validator = WorkflowValidator(base_path)
        report.workflow_issues = workflow_validator.validate()

    # Run cross-reference validation (unless filtering to single skill or non-crossref category)
    if args.check == "crossrefs" or (args.check is None and not args.skill):
        crossref_checker = CrossRefChecker()
        report.crossref_issues = crossref_checker.check(Path(args.skills_dir))

    # Format and output
    formatter = JsonFormatter() if args.format == "json" else TableFormatter()

    print(formatter.format(report))

    # Exit with appropriate code
    sys.exit(1 if report.has_errors else 0)


if __name__ == "__main__":
    main()
