"""Operations for agent documentation management.

This module provides functionality for validating and syncing agent documentation
files with frontmatter metadata.
"""

from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path
from typing import Any, cast

from erk.agent_docs.models import (
    AgentDocFrontmatter,
    AgentDocValidationResult,
    CategoryInfo,
    CategoryTripwireStats,
    CollectedTripwire,
    DocInfo,
    SyncResult,
    Tripwire,
    TripwiresIndexValidationResult,
)
from erk.core.frontmatter import parse_markdown_frontmatter
from erk_shared.subprocess_utils import run_subprocess_with_context

AGENT_DOCS_DIR = "docs/learned"

# Category descriptions for root index generation.
# Format: "Explore when [doing X]. Add docs here for [type of content]."
# To add a new category, add an entry here and run `erk docs sync`.
CATEGORY_DESCRIPTIONS: dict[str, str] = {
    "architecture": (
        "Explore when working on core patterns (dry-run, gateways, subprocess, shell integration). "
        "Add docs here for cross-cutting technical patterns."
    ),
    "documentation": (
        "Explore when structuring or writing documentation. "
        "Add docs here for documentation methodology and best practices."
    ),
    "cli": (
        "Explore when building CLI commands or output formatting. "
        "Add docs here for Click patterns and terminal UX."
    ),
    "commands": (
        "Explore when creating or optimizing slash commands. "
        "Add docs here for command authoring patterns."
    ),
    "erk": (
        "Explore when working with erk-specific workflows (worktrees, PR sync, Graphite). "
        "Add docs here for erk user-facing features."
    ),
    "hooks": (
        "Explore when creating or debugging hooks. Add docs here for hook development patterns."
    ),
    "planning": (
        "Explore when working with plans, .impl/ folders, or agent delegation. "
        "Add docs here for planning workflow patterns."
    ),
    "reference": (
        "Explore for API/format specifications. "
        "Add docs here for reference material that doesn't fit other categories."
    ),
    "sessions": (
        "Explore when working with session logs or parallel sessions. "
        "Add docs here for session management patterns."
    ),
    "testing": (
        "Explore when writing tests or debugging test infrastructure. "
        "Add docs here for testing patterns specific to erk."
    ),
    "textual": (
        "Explore when working with Textual framework. Add docs here for Textual-specific patterns."
    ),
    "tui": (
        "Explore when working on the erk TUI application. "
        "Add docs here for TUI feature implementation."
    ),
}

# Routing hints for tripwires index generation.
# Maps category name to "Load when working in" text for the routing table.
# Categories without hints get a generic entry.
CATEGORY_ROUTING_HINTS: dict[str, str] = {
    "architecture": "`src/erk/gateway/`, gateways, subprocess",
    "cli": "`src/erk/cli/`",
    "testing": "`tests/`",
    "ci": "`.github/workflows/`, `.github/actions/`",
    "tui": "`src/erk/tui/`",
    "planning": "`.impl/`, `.worker-impl/`, planning workflows",
    "sessions": "`~/.claude/projects/`, session analysis",
    "textual": "Textual framework code",
    "hooks": "`.claude/hooks/`, hook development",
    "commands": "`.claude/commands/`, slash commands",
    "capabilities": "Claude Code capabilities, tool use",
    "claude-code": "Claude Code configuration, settings",
}

# Banner for auto-generated files
GENERATED_FILE_BANNER = """<!-- AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY -->
<!-- Edit source frontmatter, then run 'erk docs sync' to regenerate. -->

"""


def _validate_tripwires(
    tripwires_data: object,
) -> tuple[list[Tripwire], list[str]]:
    """Validate the tripwires field from frontmatter.

    Args:
        tripwires_data: Raw tripwires data from YAML.

    Returns:
        Tuple of (tripwires, errors). If validation succeeds,
        errors is empty and tripwires contains parsed Tripwire objects.
    """
    errors: list[str] = []
    tripwires: list[Tripwire] = []

    if not isinstance(tripwires_data, list):
        errors.append("Field 'tripwires' must be a list")
        return [], errors

    for i, item in enumerate(tripwires_data):
        if not isinstance(item, dict):
            errors.append(f"Field 'tripwires[{i}]' must be an object")
            continue

        # Type narrowing: item is dict after isinstance check, cast for .get() typing
        item_dict = cast(dict[str, Any], item)
        action = item_dict.get("action")
        warning = item_dict.get("warning")

        if not action:
            errors.append(f"Field 'tripwires[{i}].action' is required")
        elif not isinstance(action, str):
            errors.append(f"Field 'tripwires[{i}].action' must be a string")

        if not warning:
            errors.append(f"Field 'tripwires[{i}].warning' is required")
        elif not isinstance(warning, str):
            errors.append(f"Field 'tripwires[{i}].warning' must be a string")

        # Only create Tripwire if both fields are valid strings
        if isinstance(action, str) and action and isinstance(warning, str) and warning:
            tripwires.append(Tripwire(action=action, warning=warning))

    return tripwires, errors


def validate_agent_doc_frontmatter(
    data: Mapping[str, object],
) -> tuple[AgentDocFrontmatter | None, list[str]]:
    """Validate parsed frontmatter against the schema.

    Args:
        data: Parsed YAML dictionary.

    Returns:
        Tuple of (frontmatter, errors). If validation succeeds,
        errors is empty. If validation fails, frontmatter is None.
    """
    errors: list[str] = []

    # Check title
    title = data.get("title")
    if not title:
        errors.append("Missing required field: title")
    elif not isinstance(title, str):
        errors.append("Field 'title' must be a string")

    # Check read_when
    read_when = data.get("read_when")
    if read_when is None:
        errors.append("Missing required field: read_when")
    elif not isinstance(read_when, list):
        errors.append("Field 'read_when' must be a list")
    elif len(read_when) == 0:
        errors.append("Field 'read_when' must not be empty")
    else:
        for i, item in enumerate(read_when):
            if not isinstance(item, str):
                errors.append(f"Field 'read_when[{i}]' must be a string")

    # Check tripwires (optional)
    tripwires: list[Tripwire] = []
    tripwires_data = data.get("tripwires")
    if tripwires_data is not None:
        tripwires, tripwire_errors = _validate_tripwires(tripwires_data)
        errors.extend(tripwire_errors)

    if errors:
        return None, errors

    # At this point, validation has ensured title is str and read_when is list[str]
    assert isinstance(title, str)
    assert isinstance(read_when, list) and all(isinstance(x, str) for x in read_when)
    return AgentDocFrontmatter(
        title=title,
        read_when=cast(list[str], read_when),
        tripwires=tripwires,
    ), []


def validate_agent_doc_file(file_path: Path, agent_docs_root: Path) -> AgentDocValidationResult:
    """Validate a single agent documentation file.

    Args:
        file_path: Absolute path to the markdown file.
        agent_docs_root: Path to the .erk/docs/agent directory.

    Returns:
        Validation result with any errors found.
    """
    rel_path = str(file_path.relative_to(agent_docs_root))

    if not file_path.exists():
        return AgentDocValidationResult(
            file_path=rel_path,
            frontmatter=None,
            errors=("File does not exist",),
        )

    try:
        content = file_path.read_text(encoding="utf-8")
    except Exception as e:
        return AgentDocValidationResult(
            file_path=rel_path,
            frontmatter=None,
            errors=(f"Cannot read file: {e}",),
        )

    result = parse_markdown_frontmatter(content)
    if result.error is not None:
        return AgentDocValidationResult(
            file_path=rel_path,
            frontmatter=None,
            errors=(result.error,),
        )

    # result.error is None means result.metadata is not None
    assert result.metadata is not None
    frontmatter, validation_errors = validate_agent_doc_frontmatter(result.metadata)
    return AgentDocValidationResult(
        file_path=rel_path,
        frontmatter=frontmatter,
        errors=tuple(validation_errors),
    )


def discover_agent_docs(agent_docs_root: Path) -> list[Path]:
    """Discover all markdown files in the agent docs directory.

    Args:
        agent_docs_root: Path to the .erk/docs/agent directory.

    Returns:
        List of paths to markdown files, sorted alphabetically.
    """
    if not agent_docs_root.exists():
        return []

    files: list[Path] = []
    for md_file in agent_docs_root.rglob("*.md"):
        # Skip auto-generated files (index.md, tripwires-index.md, tripwires.md)
        if md_file.name == "index.md":
            continue
        if md_file.name == "tripwires-index.md":
            continue
        # Skip auto-generated tripwires.md files (check for banner)
        if md_file.name == "tripwires.md":
            content = md_file.read_text(encoding="utf-8")
            if "AUTO-GENERATED FILE" in content:
                continue
        files.append(md_file)

    return sorted(files)


def validate_agent_docs(project_root: Path) -> list[AgentDocValidationResult]:
    """Validate all agent documentation files in a project.

    Args:
        project_root: Path to the project root.

    Returns:
        List of validation results for each file.
    """
    agent_docs_root = project_root / AGENT_DOCS_DIR
    if not agent_docs_root.exists():
        return []

    files = discover_agent_docs(agent_docs_root)
    results: list[AgentDocValidationResult] = []

    for file_path in files:
        result = validate_agent_doc_file(file_path, agent_docs_root)
        results.append(result)

    return results


def collect_tripwires(project_root: Path) -> list[CollectedTripwire]:
    """Collect all tripwires from agent documentation frontmatter.

    Args:
        project_root: Path to the project root.

    Returns:
        List of collected tripwires with their source documentation info.
    """
    agent_docs_root = project_root / AGENT_DOCS_DIR
    if not agent_docs_root.exists():
        return []

    files = discover_agent_docs(agent_docs_root)
    tripwires: list[CollectedTripwire] = []

    for file_path in files:
        result = validate_agent_doc_file(file_path, agent_docs_root)
        if not result.is_valid or result.frontmatter is None:
            continue

        rel_path = str(file_path.relative_to(agent_docs_root))

        for tripwire in result.frontmatter.tripwires:
            tripwires.append(
                CollectedTripwire(
                    action=tripwire.action,
                    warning=tripwire.warning,
                    doc_path=rel_path,
                    doc_title=result.frontmatter.title,
                )
            )

    return tripwires


def _get_category_from_path(doc_path: str) -> str | None:
    """Extract category from a doc path.

    Args:
        doc_path: Relative path like "architecture/erk-architecture.md" or "conventions.md".

    Returns:
        Category name (e.g., "architecture") or None for root-level docs.
    """
    parts = doc_path.split("/")
    if len(parts) > 1:
        return parts[0]
    return None


def group_tripwires_by_category(
    tripwires: list[CollectedTripwire],
) -> dict[str, list[CollectedTripwire]]:
    """Group tripwires by their source document's category.

    Args:
        tripwires: List of collected tripwires.

    Returns:
        Dict mapping category names to lists of tripwires.
        Root-level docs (no category) are grouped under "uncategorized".
    """
    result: dict[str, list[CollectedTripwire]] = defaultdict(list)
    for tripwire in tripwires:
        category = _get_category_from_path(tripwire.doc_path)
        if category is None:
            category = "uncategorized"
        result[category].append(tripwire)
    return dict(result)


def generate_category_tripwires_doc(
    category: str,
    tripwires: list[CollectedTripwire],
) -> str:
    """Generate tripwires doc for a single category.

    Args:
        category: Category name (e.g., "architecture", "cli").
        tripwires: List of tripwires for this category.

    Returns:
        Generated markdown content for the category tripwires file.
    """
    # Title case the category name for display
    title = category.replace("-", " ").replace("_", " ").title()

    lines = [
        "---",
        f"title: {title} Tripwires",
        "read_when:",
        f'  - "working on {category} code"',
        "---",
        "",
        GENERATED_FILE_BANNER.rstrip(),
        f"<!-- Generated from {category}/*.md frontmatter -->",
        "",
        f"# {title} Tripwires",
        "",
        "Action-triggered rules for this category. Consult BEFORE taking any matching action.",
        "",
    ]

    if not tripwires:
        lines.append("*No tripwires defined for this category.*")
        lines.append("")
        return "\n".join(lines)

    # Sort by action for consistent output
    for tripwire in sorted(tripwires, key=lambda t: t.action):
        # Use relative path from category directory
        rel_doc_path = (
            tripwire.doc_path.split("/", 1)[-1] if "/" in tripwire.doc_path else tripwire.doc_path
        )
        lines.append(
            f"**CRITICAL: Before {tripwire.action}** → "
            f"Read [{tripwire.doc_title}]({rel_doc_path}) first. "
            f"{tripwire.warning}"
        )
        lines.append("")

    return "\n".join(lines)


def generate_tripwires_index(
    tripwires_by_category: dict[str, list[CollectedTripwire]],
) -> str:
    """Generate the tripwires-index.md file content.

    Args:
        tripwires_by_category: Dict mapping category names to lists of tripwires.

    Returns:
        Generated markdown content for tripwires-index.md.
    """
    lines = [
        GENERATED_FILE_BANNER.rstrip(),
        "",
        "# Tripwires Index",
        "",
        "Complete index of category-specific tripwire files.",
        "",
        "## Universal Tripwires",
        "",
        "Load **first** for any code area: [universal-tripwires.md](universal-tripwires.md)",
        "",
        "## Category Tripwires",
        "",
        "| Category | Tripwires | Load When Working In |",
        "| -------- | --------- | -------------------- |",
    ]

    # Sort categories alphabetically
    for category in sorted(tripwires_by_category.keys()):
        tripwires = tripwires_by_category[category]
        count = len(tripwires)
        routing_hint = CATEGORY_ROUTING_HINTS.get(category, f"`{category}/` code")
        lines.append(f"| [{category}]({category}/tripwires.md) | {count} | {routing_hint} |")

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


def collect_valid_docs(project_root: Path) -> tuple[list[DocInfo], list[CategoryInfo], int]:
    """Collect all valid documentation files organized by category.

    Args:
        project_root: Path to the project root.

    Returns:
        Tuple of (uncategorized_docs, categories, invalid_count).
    """
    agent_docs_root = project_root / AGENT_DOCS_DIR
    if not agent_docs_root.exists():
        return [], [], 0

    files = discover_agent_docs(agent_docs_root)
    uncategorized: list[DocInfo] = []
    categories: dict[str, list[DocInfo]] = {}
    invalid_count = 0

    for file_path in files:
        result = validate_agent_doc_file(file_path, agent_docs_root)
        if not result.is_valid or result.frontmatter is None:
            invalid_count += 1
            continue

        rel_path = file_path.relative_to(agent_docs_root)
        doc_info = DocInfo(
            rel_path=str(rel_path),
            frontmatter=result.frontmatter,
        )

        # Check if in subdirectory (category)
        if len(rel_path.parts) > 1:
            category = rel_path.parts[0]
            if category not in categories:
                categories[category] = []
            categories[category].append(doc_info)
        else:
            uncategorized.append(doc_info)

    # Convert to CategoryInfo list
    category_list = [
        CategoryInfo(
            name=name,
            docs=tuple(sorted(docs, key=lambda d: d.rel_path)),
        )
        for name, docs in sorted(categories.items())
    ]

    return sorted(uncategorized, key=lambda d: d.rel_path), category_list, invalid_count


def generate_root_index(
    uncategorized: list[DocInfo],
    categories: list[CategoryInfo],
) -> str:
    """Generate content for the root index.md file.

    Uses bullet list format instead of tables to avoid merge conflicts.
    Each entry is independent - no separator rows that span all entries.

    Args:
        uncategorized: Docs at the root level.
        categories: List of category directories with their docs.

    Returns:
        Generated markdown content.
    """
    lines = [
        GENERATED_FILE_BANNER.rstrip(),
        "",
        "# Agent Documentation",
        "",
        "Before starting work, scan the read-when conditions below.",
        "If your current task matches, read the linked document **before writing code**.",
        "",
    ]

    if categories:
        lines.append("## Categories")
        lines.append("")
        for category in categories:
            description = CATEGORY_DESCRIPTIONS.get(category.name)
            if description:
                lines.append(f"- [{category.name}/]({category.name}/) — {description}")
            else:
                lines.append(f"- [{category.name}/]({category.name}/)")
        lines.append("")

    if uncategorized:
        lines.append("## Uncategorized")
        lines.append("")
        for doc in uncategorized:
            read_when = ", ".join(doc.frontmatter.read_when)
            lines.append(f"- **[{doc.rel_path}]({doc.rel_path})** — {read_when}")
        lines.append("")

    if not categories and not uncategorized:
        lines.append("*No documentation files found.*")
        lines.append("")

    return "\n".join(lines)


def generate_category_index(category: CategoryInfo) -> str:
    """Generate content for a category's index.md file.

    Uses bullet list format instead of tables to avoid merge conflicts.
    Each entry is independent - no separator rows that span all entries.

    Args:
        category: Category information with docs.

    Returns:
        Generated markdown content.
    """
    # Title case the category name
    title = category.name.replace("-", " ").replace("_", " ").title()

    lines = [GENERATED_FILE_BANNER.rstrip(), "", f"# {title} Documentation", ""]

    for doc in category.docs:
        # Use just the filename for relative links within category
        filename = Path(doc.rel_path).name
        read_when = ", ".join(doc.frontmatter.read_when)
        lines.append(f"- **[{filename}]({filename})** — {read_when}")

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


def _format_with_prettier(content: str, file_path: Path) -> str:
    """Format markdown content with prettier.

    Runs prettier twice to ensure idempotent output. Prettier converts
    underscore emphasis (__text__) to asterisk emphasis on first pass,
    then escapes asterisks on second pass. A single pass causes cycling
    between `erk docs sync` and `make prettier --write`.

    Args:
        content: The markdown content to format.
        file_path: Path to use for prettier's parser detection.

    Returns:
        Formatted content.
    """
    # First pass: normalize emphasis markers and basic formatting
    result = run_subprocess_with_context(
        cmd=["prettier", "--stdin-filepath", str(file_path)],
        operation_context="format markdown with prettier (pass 1)",
        input=content,
    )
    # Second pass: escape any asterisks that would be re-interpreted
    result = run_subprocess_with_context(
        cmd=["prettier", "--stdin-filepath", str(file_path)],
        operation_context="format markdown with prettier (pass 2)",
        input=result.stdout,
    )
    return result.stdout


def _update_index_file(
    *,
    index_path: Path,
    content: str,
    created: list[str],
    updated: list[str],
    unchanged: list[str],
    dry_run: bool,
) -> None:
    """Update an index file if content changed.

    Args:
        index_path: Path to the index file.
        content: New content to write.
        created: List to append if file was created.
        updated: List to append if file was updated.
        unchanged: List to append if file was unchanged.
        dry_run: If True, don't actually write.
    """
    rel_path = str(index_path.relative_to(index_path.parent.parent.parent))

    # Format content with prettier before comparing or writing
    formatted_content = _format_with_prettier(content, index_path)

    if not index_path.exists():
        if not dry_run:
            index_path.write_text(formatted_content, encoding="utf-8")
        created.append(rel_path)
        return

    existing = index_path.read_text(encoding="utf-8")
    if existing == formatted_content:
        unchanged.append(rel_path)
        return

    if not dry_run:
        index_path.write_text(formatted_content, encoding="utf-8")
    updated.append(rel_path)


def _update_generated_file(
    *,
    file_path: Path,
    content: str,
    created: list[str],
    updated: list[str],
    unchanged: list[str],
    dry_run: bool,
    agent_docs_root: Path,
) -> None:
    """Update a generated file if content changed.

    Args:
        file_path: Path to the generated file.
        content: New content to write.
        created: List to append if file was created.
        updated: List to append if file was updated.
        unchanged: List to append if file was unchanged.
        dry_run: If True, don't actually write.
        agent_docs_root: Path to .erk/docs/agent/ for relative path calculation.
    """
    rel_path = str(file_path.relative_to(agent_docs_root.parent.parent))

    # Format content with prettier before comparing or writing
    formatted_content = _format_with_prettier(content, file_path)

    if not file_path.exists():
        if not dry_run:
            file_path.parent.mkdir(parents=True, exist_ok=True)
            file_path.write_text(formatted_content, encoding="utf-8")
        created.append(rel_path)
        return

    existing = file_path.read_text(encoding="utf-8")
    if existing == formatted_content:
        unchanged.append(rel_path)
        return

    if not dry_run:
        file_path.write_text(formatted_content, encoding="utf-8")
    updated.append(rel_path)


def sync_agent_docs(project_root: Path, *, dry_run: bool) -> SyncResult:
    """Sync agent documentation index files from frontmatter.

    Generates index.md files for the root docs/learned/ directory and
    each subdirectory (category) that contains 2+ docs. Also generates
    per-category tripwires.md files (e.g., architecture/tripwires.md).

    Args:
        project_root: Path to the project root.
        dry_run: If True, don't write files, just report what would change.

    Returns:
        SyncResult with lists of created, updated, and unchanged files.
    """
    agent_docs_root = project_root / AGENT_DOCS_DIR
    if not agent_docs_root.exists():
        return SyncResult(
            created=(),
            updated=(),
            unchanged=(),
            skipped_invalid=0,
            tripwires_count=0,
            tripwires_by_category=(),
        )

    uncategorized, categories, invalid_count = collect_valid_docs(project_root)

    created: list[str] = []
    updated: list[str] = []
    unchanged: list[str] = []

    # Generate root index
    root_index_path = agent_docs_root / "index.md"
    root_content = generate_root_index(uncategorized, categories)
    _update_index_file(
        index_path=root_index_path,
        content=root_content,
        created=created,
        updated=updated,
        unchanged=unchanged,
        dry_run=dry_run,
    )

    # Generate category indexes (only for categories with 2+ docs)
    for category in categories:
        if len(category.docs) < 2:
            continue

        category_index_path = agent_docs_root / category.name / "index.md"
        category_content = generate_category_index(category)
        _update_index_file(
            index_path=category_index_path,
            content=category_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
        )

    # Collect and generate per-category tripwire files
    tripwires = collect_tripwires(project_root)
    tripwires_count = len(tripwires)

    tripwires_by_category = group_tripwires_by_category(tripwires)
    category_stats: list[CategoryTripwireStats] = []

    for category_name, category_tripwires in sorted(tripwires_by_category.items()):
        # Generate tripwires file for this category
        tripwires_path = agent_docs_root / category_name / "tripwires.md"
        tripwires_content = generate_category_tripwires_doc(category_name, category_tripwires)
        _update_generated_file(
            file_path=tripwires_path,
            content=tripwires_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
            agent_docs_root=agent_docs_root,
        )
        category_stats.append(
            CategoryTripwireStats(category=category_name, count=len(category_tripwires))
        )

    # Generate tripwires index (only if there are tripwires)
    if tripwires_by_category:
        tripwires_index_path = agent_docs_root / "tripwires-index.md"
        tripwires_index_content = generate_tripwires_index(tripwires_by_category)
        _update_generated_file(
            file_path=tripwires_index_path,
            content=tripwires_index_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
            agent_docs_root=agent_docs_root,
        )

    return SyncResult(
        created=tuple(created),
        updated=tuple(updated),
        unchanged=tuple(unchanged),
        skipped_invalid=invalid_count,
        tripwires_count=tripwires_count,
        tripwires_by_category=tuple(category_stats),
    )


def validate_tripwires_index(project_root: Path) -> TripwiresIndexValidationResult:
    """Validate that tripwires-index.md is complete and up-to-date.

    Checks that:
    1. tripwires-index.md exists
    2. Every tripwires.md file is listed in the index

    Args:
        project_root: Path to the project root.

    Returns:
        Validation result with any errors found.
    """
    agent_docs_root = project_root / AGENT_DOCS_DIR
    tripwires_index_path = agent_docs_root / "tripwires-index.md"

    # Check if index exists
    if not tripwires_index_path.exists():
        return TripwiresIndexValidationResult(
            is_valid=False,
            errors=("tripwires-index.md does not exist. Run 'erk docs sync' to generate it.",),
            missing_from_index=(),
            index_exists=False,
        )

    # Read the index content
    index_content = tripwires_index_path.read_text(encoding="utf-8")

    # Find all auto-generated tripwires.md files (those with the AUTO-GENERATED banner)
    tripwire_files = list(agent_docs_root.glob("*/tripwires.md"))

    # Check each auto-generated tripwire file is in the index
    missing_from_index: list[str] = []
    for tripwire_file in tripwire_files:
        # Skip manually-authored tripwires docs (without AUTO-GENERATED banner)
        file_content = tripwire_file.read_text(encoding="utf-8")
        if "AUTO-GENERATED FILE" not in file_content:
            continue

        category = tripwire_file.parent.name
        # Check for the link format: [category](category/tripwires.md)
        expected_link = f"[{category}]({category}/tripwires.md)"
        if expected_link not in index_content:
            missing_from_index.append(f"{category}/tripwires.md")

    errors: list[str] = []
    if missing_from_index:
        errors.append(
            f"Tripwires index is incomplete. Missing: {', '.join(sorted(missing_from_index))}. "
            "Run 'erk docs sync' to regenerate."
        )

    return TripwiresIndexValidationResult(
        is_valid=len(errors) == 0,
        errors=tuple(errors),
        missing_from_index=tuple(sorted(missing_from_index)),
        index_exists=True,
    )
