"""Operations for agent documentation management.

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

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

import click

from erk.agent_docs.models import (
    AgentDocFrontmatter,
    AgentDocValidationResult,
    AuditResult,
    CategoryInfo,
    CategoryTripwireStats,
    CollectedTripwire,
    DocInfo,
    SyncResult,
    Tripwire,
    TripwiresIndexValidationResult,
)
from erk_shared.core.frontmatter import parse_markdown_frontmatter
from erk_shared.gateway.agent_docs.abc import AgentDocs


def resolve_docs_project_root(*, repo_root: Path, docs_path: str | None) -> Path:
    """Resolve the project root for docs operations.

    When docs_path is configured, docs operations target the external
    repo instead of the main project repo.
    """
    if docs_path is None:
        return repo_root
    path = Path(docs_path)
    if not path.exists():
        raise click.ClickException(f"Configured docs.path does not exist: {docs_path}")
    return path


AGENT_DOCS_DIR = "docs/learned"

LAST_AUDITED_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2} PT$")

# 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, .erk/impl-context/ 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": "`.erk/impl-context/`, 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")

        # Validate optional pattern field
        pattern: str | None = None
        pattern_data = item_dict.get("pattern")
        if pattern_data is not None:
            if not isinstance(pattern_data, str):
                errors.append(f"Field 'tripwires[{i}].pattern' must be a string")
            elif not pattern_data:
                errors.append(f"Field 'tripwires[{i}].pattern' must not be empty")
            else:
                try:
                    re.compile(pattern_data)
                except re.error as e:
                    errors.append(f"Field 'tripwires[{i}].pattern' is not a valid regex: {e}")
                else:
                    pattern = pattern_data

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

    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)

    # Check last_audited (optional)
    last_audited: str | None = None
    last_audited_data = data.get("last_audited")
    if last_audited_data is not None:
        if not isinstance(last_audited_data, str):
            errors.append("Field 'last_audited' must be a string")
        elif not LAST_AUDITED_PATTERN.match(last_audited_data):
            errors.append(
                "Field 'last_audited' must match format"
                f" 'YYYY-MM-DD HH:MM PT', got '{last_audited_data}'"
            )
        else:
            last_audited = last_audited_data

    # Check audit_result (optional)
    audit_result: AuditResult | None = None
    audit_result_data = data.get("audit_result")
    if audit_result_data is not None:
        if not isinstance(audit_result_data, str):
            errors.append("Field 'audit_result' must be a string")
        elif audit_result_data not in get_args(AuditResult):
            errors.append(
                f"Field 'audit_result' must be 'clean' or 'edited', got '{audit_result_data}'"
            )
        else:
            audit_result = cast(AuditResult, audit_result_data)

    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,
        last_audited=last_audited,
        audit_result=audit_result,
    ), []


def validate_agent_doc_content(rel_path: str, content: str) -> AgentDocValidationResult:
    """Validate a single agent documentation file's content.

    Args:
        rel_path: Relative path under docs/learned/ (e.g., "architecture/subprocess-wrappers.md").
        content: File content to validate.

    Returns:
        Validation result with any errors found.
    """
    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: AgentDocs, project_root: Path) -> list[str]:
    """Discover all markdown files in the agent docs directory.

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Root of the project.

    Returns:
        List of relative paths under docs/learned/, sorted alphabetically.
    """
    if not agent_docs.has_docs_dir(project_root):
        return []

    # Get all files from gateway
    all_files = agent_docs.list_files(project_root)

    # Filter out auto-generated files
    files: list[str] = []
    for rel_path in all_files:
        filename = Path(rel_path).name
        # Skip auto-generated files (index.md, tripwires-index.md, tripwires.md)
        if filename == "index.md":
            continue
        if filename == "tripwires-index.md":
            continue
        # Skip auto-generated tripwires.md files (check for banner)
        if filename == "tripwires.md":
            content = agent_docs.read_file(project_root, rel_path)
            if content is not None and "AUTO-GENERATED FILE" in content:
                continue
        files.append(rel_path)

    return sorted(files)


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

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Path to the project root.

    Returns:
        List of validation results for each file.
    """
    if not agent_docs.has_docs_dir(project_root):
        return []

    rel_paths = discover_agent_docs(agent_docs, project_root)
    results: list[AgentDocValidationResult] = []

    for rel_path in rel_paths:
        content = agent_docs.read_file(project_root, rel_path)
        if content is None:
            results.append(
                AgentDocValidationResult(
                    file_path=rel_path,
                    frontmatter=None,
                    errors=("File does not exist",),
                )
            )
            continue
        result = validate_agent_doc_content(rel_path, content)
        results.append(result)

    return results


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

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Path to the project root.

    Returns:
        List of collected tripwires with their source documentation info.
    """
    if not agent_docs.has_docs_dir(project_root):
        return []

    rel_paths = discover_agent_docs(agent_docs, project_root)
    tripwires: list[CollectedTripwire] = []

    for rel_path in rel_paths:
        content = agent_docs.read_file(project_root, rel_path)
        if content is None:
            continue
        result = validate_agent_doc_content(rel_path, content)
        if not result.is_valid or result.frontmatter is None:
            continue

        for tripwire in result.frontmatter.tripwires:
            tripwires.append(
                CollectedTripwire(
                    action=tripwire.action,
                    warning=tripwire.warning,
                    pattern=tripwire.pattern,
                    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",
        "",
        "Rules triggered by matching actions in code.",
        "",
    ]

    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
        )
        pattern_annotation = ""
        if tripwire.pattern is not None:
            pattern_annotation = f" [pattern: `{tripwire.pattern}`]"
        lines.append(
            f"**{tripwire.action}**{pattern_annotation} → "
            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(
    agent_docs: AgentDocs, project_root: Path
) -> tuple[list[DocInfo], list[CategoryInfo], int]:
    """Collect all valid documentation files organized by category.

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Path to the project root.

    Returns:
        Tuple of (uncategorized_docs, categories, invalid_count).
    """
    if not agent_docs.has_docs_dir(project_root):
        return [], [], 0

    rel_paths = discover_agent_docs(agent_docs, project_root)
    uncategorized: list[DocInfo] = []
    categories: dict[str, list[DocInfo]] = {}
    invalid_count = 0

    for rel_path in rel_paths:
        content = agent_docs.read_file(project_root, rel_path)
        if content is None:
            invalid_count += 1
            continue
        result = validate_agent_doc_content(rel_path, content)
        if not result.is_valid or result.frontmatter is None:
            invalid_count += 1
            continue

        doc_info = DocInfo(
            rel_path=rel_path,
            frontmatter=result.frontmatter,
        )

        # Check if in subdirectory (category)
        path_parts = Path(rel_path).parts
        if len(path_parts) > 1:
            category = 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 path relative to category directory for links
        rel_to_category = str(Path(doc.rel_path).relative_to(category.name))
        read_when = ", ".join(doc.frontmatter.read_when)
        lines.append(f"- **[{rel_to_category}]({rel_to_category})** — {read_when}")

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


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

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Root of the project.
        rel_path: Relative path under docs/learned/ (e.g., "architecture/index.md").
        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.
    """
    # Format content with prettier before comparing or writing
    formatted_content = agent_docs.format_markdown(content)

    existing = agent_docs.read_file(project_root, rel_path)
    if existing is None:
        if not dry_run:
            agent_docs.write_file(project_root, rel_path, formatted_content)
        created.append(rel_path)
        return

    if existing == formatted_content:
        unchanged.append(rel_path)
        return

    if not dry_run:
        agent_docs.write_file(project_root, rel_path, formatted_content)
    updated.append(rel_path)


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

    Args:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Root of the project.
        rel_path: Relative path under docs/learned/ (e.g., "architecture/tripwires.md").
        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.
    """
    # Format content with prettier before comparing or writing
    formatted_content = agent_docs.format_markdown(content)

    existing = agent_docs.read_file(project_root, rel_path)
    if existing is None:
        if not dry_run:
            agent_docs.write_file(project_root, rel_path, formatted_content)
        created.append(rel_path)
        return

    if existing == formatted_content:
        unchanged.append(rel_path)
        return

    if not dry_run:
        agent_docs.write_file(project_root, rel_path, formatted_content)
    updated.append(rel_path)


def sync_agent_docs(
    agent_docs: AgentDocs,
    project_root: Path,
    *,
    dry_run: bool,
    on_progress: Callable[[str], None],
) -> 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:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Path to the project root.
        dry_run: If True, don't write files, just report what would change.
        on_progress: Callback for progress messages.

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

    on_progress("Scanning docs...")
    uncategorized, categories, invalid_count = collect_valid_docs(agent_docs, project_root)

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

    # Generate root index
    on_progress("Generating root index...")
    root_content = generate_root_index(uncategorized, categories)
    _update_index_file(
        agent_docs,
        project_root,
        rel_path="index.md",
        content=root_content,
        created=created,
        updated=updated,
        unchanged=unchanged,
        dry_run=dry_run,
    )

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

        category_content = generate_category_index(category)
        _update_index_file(
            agent_docs,
            project_root,
            rel_path=f"{category.name}/index.md",
            content=category_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
        )

    # Collect and generate per-category tripwire files
    on_progress("Collecting tripwires...")
    tripwires = collect_tripwires(agent_docs, project_root)
    tripwires_count = len(tripwires)

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

    on_progress("Generating tripwire files...")
    for category_name, category_tripwires in sorted(tripwires_by_category.items()):
        # Generate tripwires file for this category
        tripwires_content = generate_category_tripwires_doc(category_name, category_tripwires)
        _update_generated_file(
            agent_docs,
            project_root,
            rel_path=f"{category_name}/tripwires.md",
            content=tripwires_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
        )
        pattern_count = sum(1 for t in category_tripwires if t.pattern is not None)
        category_stats.append(
            CategoryTripwireStats(
                category=category_name,
                count=len(category_tripwires),
                pattern_count=pattern_count,
            )
        )

    # Generate tripwires index (only if there are tripwires)
    on_progress("Generating tripwires index...")
    if tripwires_by_category:
        tripwires_index_content = generate_tripwires_index(tripwires_by_category)
        _update_generated_file(
            agent_docs,
            project_root,
            rel_path="tripwires-index.md",
            content=tripwires_index_content,
            created=created,
            updated=updated,
            unchanged=unchanged,
            dry_run=dry_run,
        )

    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(
    agent_docs: AgentDocs, 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:
        agent_docs: Gateway for accessing docs/learned/ files.
        project_root: Path to the project root.

    Returns:
        Validation result with any errors found.
    """
    # Check if index exists
    index_content = agent_docs.read_file(project_root, "tripwires-index.md")
    if index_content is None:
        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,
        )

    # Find all auto-generated tripwires.md files (those with the AUTO-GENERATED banner)
    all_files = agent_docs.list_files(project_root)
    tripwire_files = [f for f in all_files if f.endswith("/tripwires.md")]

    # Check each auto-generated tripwire file is in the index
    missing_from_index: list[str] = []
    for tripwire_rel_path in tripwire_files:
        # Skip manually-authored tripwires docs (without AUTO-GENERATED banner)
        file_content = agent_docs.read_file(project_root, tripwire_rel_path)
        if file_content is None or "AUTO-GENERATED FILE" not in file_content:
            continue

        # Extract category from path (e.g., "architecture/tripwires.md" -> "architecture")
        category = Path(tripwire_rel_path).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,
    )
