from __future__ import annotations

import os
import re
import sys
from pathlib import Path

import click

from . import __version__
from ._subprocess import TEXT_MODE_KWARGS
from .core import GreatDocs


def _detect_python_version_from_pyproject(project_root: Path) -> str | None:
    """Detect the minimum Python version from pyproject.toml.

    Parses the `requires-python` field (e.g., '>=3.12', '>=3.10,<3.13')
    and returns a suitable Python version string for CI (e.g., '3.12').

    Returns None if pyproject.toml doesn't exist or has no version requirement.
    """
    pyproject_path = project_root / "pyproject.toml"
    if not pyproject_path.exists():
        return None

    try:
        # Use tomllib (Python 3.11+) or tomli as fallback
        try:
            import tomllib
        except ImportError:
            import tomli as tomllib

        with open(pyproject_path, "rb") as f:
            data = tomllib.load(f)

        requires_python = data.get("project", {}).get("requires-python")
        if not requires_python:
            return None

        # Parse version specifier to find minimum version
        # Common patterns: ">=3.12", ">=3.10,<3.13", "~=3.11", ">=3.9"
        # Extract versions from specifiers
        version_pattern = r"(\d+\.\d+)"
        matches = re.findall(version_pattern, requires_python)

        if not matches:
            return None

        # For >= specifiers, use the specified version
        if ">=" in requires_python or "~=" in requires_python:
            # Return the first (minimum) version found
            return matches[0]

        # For other specifiers, try to pick a reasonable version
        # Find the highest version mentioned (likely the target)
        versions = [tuple(map(int, v.split("."))) for v in matches]
        max_version = max(versions)
        return f"{max_version[0]}.{max_version[1]}"

    except Exception:
        # If parsing fails, return None to use default
        return None


def _detect_package_manager(project_root: Path) -> str:
    """Detect the package manager used by the project.

    Checks for lock files to determine which package manager is in use:
    - uv.lock -> uv
    - poetry.lock -> poetry
    - Otherwise -> pip (default)

    Returns:
        One of: 'uv', 'poetry', or 'pip'
    """
    if (project_root / "uv.lock").exists():
        return "uv"
    if (project_root / "poetry.lock").exists():
        return "poetry"
    return "pip"


def _detect_optional_dependencies(project_root: Path) -> list[str]:
    """Detect optional dependency groups from pyproject.toml.

    Returns a list of optional dependency group names that are likely
    related to documentation or development (e.g., 'dev', 'docs', 'test').

    This helps generate appropriate pip install commands like:
    pip install -e ".[dev,docs]"
    """
    pyproject_path = project_root / "pyproject.toml"
    if not pyproject_path.exists():
        return []

    try:
        try:
            import tomllib
        except ImportError:
            import tomli as tomllib

        with open(pyproject_path, "rb") as f:
            data = tomllib.load(f)

        optional_deps = data.get("project", {}).get("optional-dependencies", {})
        if not optional_deps:
            return []

        # Look for common dev/docs dependency group names
        doc_related_extras = []
        for key in optional_deps.keys():
            key_lower = key.lower()
            # Include extras that are likely needed for documentation builds
            if any(term in key_lower for term in ["dev", "doc", "notebook", "test", "all", "full"]):
                doc_related_extras.append(key)

        return sorted(doc_related_extras)

    except Exception:
        return []


class OrderedGroup(click.Group):
    """Click group that lists commands in the order they were added."""

    def list_commands(self, ctx: click.Context) -> list[str]:
        return list(self.commands.keys())


@click.group(cls=OrderedGroup)
@click.version_option(version=__version__, prog_name="great-docs")
def cli():
    """Great Docs: Beautiful documentation for Python packages.

    Great Docs generates professional documentation sites with auto-generated
    API references, CLI documentation, smart navigation, and modern styling.

    Get started with 'great-docs init' to set up your docs, then use
    'great-docs build' to generate your site.
    """
    pass


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--force",
    is_flag=True,
    help="Delete existing great-docs.yml and generate a fresh default config",
)
def init(project_path: str | None, force: bool) -> None:
    """Initialize great-docs in your project (one-time bootstrap).

    Creates a fresh 'great-docs.yml' configuration file with discovered
    package exports and sensible defaults. Refuses to run if
    'great-docs.yml' already exists (use '--force' to reset).

    \b
    • Creates 'great-docs.yml' with discovered API exports
    • Auto-detects your package name and public API
    • Updates .gitignore to exclude the build directory
    • Detects docstring style (numpy, google, sphinx)

    After init, customize 'great-docs.yml' then use 'great-docs build'
    for all subsequent builds. You should never need to run
    'great-docs init' again unless you want to completely reset your
    configuration.

    \b
    Examples:
      great-docs init                       # Initialize in current directory
      great-docs init --force               # Reset config to defaults
      great-docs init --project-path ../pkg # Initialize in another project
    """
    try:
        docs = GreatDocs(project_path=project_path)
        docs.install(force=force)
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--watch",
    is_flag=True,
    help="Watch for changes and rebuild automatically",
)
@click.option(
    "--no-refresh",
    is_flag=True,
    help="Skip re-discovering package exports (faster rebuild when API unchanged)",
)
@click.option(
    "--versions",
    "version_filter",
    type=str,
    default=None,
    help="Build only specific versions (comma-separated tags, e.g. '0.3,dev')",
)
@click.option(
    "--latest-only",
    is_flag=True,
    help="Build only the latest version (skip historical versions)",
)
@click.option(
    "--from-repo",
    "from_repo",
    type=str,
    default=None,
    help="Clone a remote Git repository and build its docs (HTTPS or SSH URL)",
)
@click.option(
    "--branch",
    type=str,
    default=None,
    help="Branch or tag to check out when using --from-repo (default: repo default)",
)
@click.option(
    "--output-dir",
    type=click.Path(file_okay=False, dir_okay=True),
    default=None,
    help="Where to copy the built site when using --from-repo (default: ./great-docs/_site)",
)
@click.option(
    "--shallow",
    is_flag=True,
    help="Force shallow clone with --from-repo (fastest, but no versioned docs or page dates)",
)
@click.option(
    "--preview",
    "preview_after",
    is_flag=True,
    help="Start a preview server after building with --from-repo",
)
def build(
    project_path: str | None,
    watch: bool,
    no_refresh: bool,
    version_filter: str | None,
    latest_only: bool,
    from_repo: str | None,
    branch: str | None,
    output_dir: str | None,
    shallow: bool,
    preview_after: bool,
) -> None:
    """Build your documentation site.

    Requires 'great-docs.yml' to exist (run 'great-docs init' first).
    This is the only command you need day-to-day and in CI.

    Creates the 'great-docs/' build directory, copies all assets,
    and builds the documentation site. The build directory is ephemeral and
    should not be committed to version control.

    Use '--project-path' to point to a project in a different directory.
    Use '--watch' to automatically rebuild when source files change.

    Use '--no-refresh' to skip API discovery for faster rebuilds when your
    package's public API hasn't changed.

    When multi-version documentation is configured, use '--versions' to
    build only specific versions, or '--latest-only' to skip historical
    versions.

    Use '--from-repo' to build documentation from a remote Git repository.
    This clones the repo into a temporary directory, creates an isolated
    virtual environment, installs the package and great-docs, builds the
    site, and copies the output to '--output-dir' (or './great-docs/_site').

    Add '--preview' to automatically start a local server after a
    '--from-repo' build completes, opening the site in your browser.

    \b
    Examples:
      great-docs build                      # Full build with API refresh
      great-docs build --no-refresh         # Fast rebuild (skip API discovery)
      great-docs build --watch              # Rebuild on file changes
      great-docs build --versions 0.3,dev   # Build specific versions only
      great-docs build --latest-only        # Build only the latest version
      great-docs build --project-path ../pkg
      great-docs build --from-repo https://github.com/owner/pkg.git
      great-docs build --from-repo git@github.com:owner/pkg.git --branch v1.0
      great-docs build --from-repo https://github.com/owner/pkg.git --output-dir ./site
      great-docs build --from-repo https://github.com/owner/pkg.git --shallow
      great-docs build --from-repo https://github.com/owner/pkg.git --preview
    """
    try:
        if from_repo:
            # Remote build: clone, install, build, copy output
            if project_path:
                click.echo(
                    "Warning: --project-path is ignored when --from-repo is used",
                    err=True,
                )
            if watch:
                click.echo("Error: --watch is not supported with --from-repo", err=True)
                sys.exit(1)
            version_tags = None
            if version_filter:
                version_tags = [v.strip() for v in version_filter.split(",") if v.strip()]
            GreatDocs.build_from_repo(
                from_repo,
                branch=branch,
                output_dir=output_dir,
                refresh=not no_refresh,
                version_tags=version_tags,
                latest_only=latest_only,
                shallow=shallow,
            )
            if preview_after:
                site_path = output_dir or str(Path.cwd() / "great-docs" / "_site")
                GreatDocs.preview_site(site_path)
        else:
            if branch:
                click.echo("Warning: --branch is ignored without --from-repo", err=True)
            if shallow:
                click.echo("Warning: --shallow is ignored without --from-repo", err=True)
            if output_dir:
                click.echo("Warning: --output-dir is ignored without --from-repo", err=True)
            if preview_after:
                click.echo("Warning: --preview is ignored without --from-repo", err=True)
            docs = GreatDocs(project_path=project_path)
            # Parse version filter if provided
            version_tags = None
            if version_filter:
                version_tags = [v.strip() for v in version_filter.split(",") if v.strip()]
            docs.build(
                watch=watch,
                refresh=not no_refresh,
                version_tags=version_tags,
                latest_only=latest_only,
            )
    except KeyboardInterrupt:
        click.echo("\n👋 Stopped watching")
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
def uninstall(project_path: str | None) -> None:
    """Remove great-docs from your project.

    This command removes the great-docs configuration and build directory:

    \b
    • Deletes the 'great-docs.yml' configuration file
    • Removes the 'great-docs/' build directory

    Your source files ('user_guide/', 'README.md', etc.) are preserved.

    \b
    Examples:
      great-docs uninstall                  # Remove from current project
    """
    try:
        docs = GreatDocs(project_path=project_path)
        docs.uninstall()
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--port",
    type=int,
    default=3000,
    show_default=True,
    help="Port for the local preview server",
)
@click.option(
    "--site-dir",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    default=None,
    help="Path to a pre-built site directory to serve (bypasses project detection)",
)
@click.option("--pr", type=int, default=None, help="Preview the CI docs build for this PR number.")
@click.option("--run", type=int, default=None, help="Preview a specific workflow run id.")
@click.option("--branch", default=None, help="Preview the newest CI docs build for a branch.")
@click.option(
    "--repo",
    default=None,
    help="GitHub repo as 'owner/repo' (default: detect from git remote / config).",
)
@click.option(
    "--artifact",
    default="docs-html",
    show_default=True,
    help="Name of the CI artifact to fetch.",
)
@click.option(
    "--path",
    "open_path",
    default="",
    help="Open the browser at this page within the site (e.g. reference/index.html).",
)
@click.option("--no-open", is_flag=True, help="Serve without launching a browser.")
@click.option("--refresh", is_flag=True, help="Ignore the local cache and re-download.")
@click.option(
    "--clear-cache",
    is_flag=True,
    help="Delete the downloaded PR-preview cache and exit.",
)
@click.option(
    "--use-gh",
    is_flag=True,
    help="Fetch via the 'gh' CLI (uses your existing gh auth) instead of a token.",
)
@click.option(
    "--env-file",
    type=click.Path(exists=True, dir_okay=False),
    default=None,
    help="Load a .env for GITHUB_TOKEN/GH_TOKEN (default: auto-detect .env).",
)
def preview(
    project_path: str | None,
    port: int,
    site_dir: str | None,
    pr: int | None,
    run: int | None,
    branch: str | None,
    repo: str | None,
    artifact: str,
    open_path: str,
    no_open: bool,
    refresh: bool,
    clear_cache: bool,
    use_gh: bool,
    env_file: str | None,
) -> None:
    """Preview your documentation locally.

    Starts a local HTTP server and opens the built documentation site in your
    default browser. If the site hasn't been built yet, it will build it first.

    The site is served from 'great-docs/_site/'. Use 'great-docs build' to
    rebuild if you've made changes.

    Use '--site-dir' to preview a site from any directory (e.g. output from
    a '--from-repo' build).

    Use '--pr', '--run', or '--branch' to fetch and preview a site that CI
    already built (no hosting setup required). Downloading CI artifacts needs a
    GitHub token with 'Actions: read' (via GITHUB_TOKEN, a .env, or 'gh auth
    login' + '--use-gh'). For fork PRs you'll be viewing contributor-authored
    HTML locally.

    \b
    Examples:
      great-docs preview                    # Preview on port 3000
      great-docs preview --port 8080        # Preview on port 8080
      great-docs preview --site-dir /tmp/weathervault-site
      great-docs preview --pr 302           # Newest CI build for PR #302
      great-docs preview --run 18273645521  # A specific workflow run
      great-docs preview --pr 302 --path reference/mcp/gd_config.html
    """
    if clear_cache:
        from ._pr_preview import clear_cache as _clear_cache

        existed, path = _clear_cache()
        if existed:
            click.echo(f"✓ Cleared PR-preview cache at {path}")
        else:
            click.echo(f"No PR-preview cache to clear ({path} does not exist).")
        return

    exclusive = [
        ("--pr", pr is not None),
        ("--run", run is not None),
        ("--branch", branch is not None),
    ]
    given = [name for name, present in exclusive if present]
    if len(given) > 1:
        click.echo(f"Error: {', '.join(given)} are mutually exclusive; pass only one.", err=True)
        sys.exit(1)

    try:
        if given:
            if site_dir:
                click.echo("Warning: --site-dir is ignored with --pr/--run/--branch", err=True)
            from ._pr_preview import PreviewError, preview_pr

            try:
                preview_pr(
                    project_path,
                    pr=pr,
                    run=run,
                    branch=branch,
                    repo=repo,
                    artifact=artifact,
                    path=open_path,
                    port=port,
                    open_browser=not no_open,
                    refresh=refresh,
                    use_gh=use_gh,
                    env_file=env_file,
                )
            except PreviewError as e:
                click.echo(f"Error: {e}", err=True)
                sys.exit(1)
        elif site_dir:
            if project_path:
                click.echo(
                    "Warning: --project-path is ignored when --site-dir is used",
                    err=True,
                )
            GreatDocs.preview_site(
                site_dir, port=port, open_path=open_path, open_browser=not no_open
            )
        else:
            docs = GreatDocs(project_path=project_path)
            docs.preview(port=port)
    except KeyboardInterrupt:
        click.echo("\n👋 Server stopped")
    except SystemExit:
        raise
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--force",
    is_flag=True,
    help="Overwrite existing great-docs.yml without prompting",
)
def config(project_path: str | None, force: bool) -> None:
    """Generate a great-docs.yml configuration file.

    Creates a 'great-docs.yml' file with all available options documented.
    The generated file contains commented examples for each setting.

    \b
    Examples:
      great-docs config                     # Generate in current directory
      great-docs config --force             # Overwrite existing file
      great-docs config --project-path ../pkg
    """
    from pathlib import Path

    from .config import create_default_config

    try:
        project_root = Path(project_path) if project_path else Path.cwd()
        config_path = project_root / "great-docs.yml"

        if config_path.exists() and not force:
            if not click.confirm(
                f"⚠️  Configuration file already exists at {config_path}\n   Overwrite it?"
            ):
                click.echo("Cancelled.")
                return

        config_content = create_default_config()
        config_path.write_text(config_content, encoding="utf-8")
        click.echo(f"✓ Created {config_path}")
        click.echo("\nEdit this file to customize your documentation settings.")
        click.echo("See https://posit-dev.github.io/great-docs/user-guide/configuration.html")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.group(cls=OrderedGroup)
def ci() -> None:
    """Helpers meant to run inside CI (GitHub Actions).

    These emit the "preview this build locally" hints that let reviewers open a
    pull request's docs without a preview host: a workflow log notice and a
    sticky PR comment, both pointing at 'great-docs preview'.

    \b
    Examples:
      great-docs ci notice --run "$GITHUB_RUN_ID" --pr 302
      great-docs ci pr-comment --run "$GITHUB_RUN_ID" --pr 302
    """


@click.command(name="pr-comment")
@click.option(
    "--run", "run_id", type=int, required=True, help="Workflow run id that built the site."
)
@click.option("--pr", type=int, required=True, help="Pull request number to comment on.")
@click.option(
    "--repo",
    default=None,
    help="GitHub repo as 'owner/repo' (default: $GITHUB_REPOSITORY, then git remote).",
)
def ci_pr_comment(run_id: int, pr: int, repo: str | None) -> None:
    """Post or refresh a sticky PR comment with the local-preview command.

    Reads the token from GITHUB_TOKEN / GH_TOKEN and needs 'pull-requests: write'.
    """
    from ._ci import post_preview_comment
    from ._pr_preview import PreviewError

    try:
        action, repo_slug = post_preview_comment(run_id=run_id, pr=pr, repo_override=repo)
        click.echo(f"✓ {action.capitalize()} preview comment on {repo_slug}#{pr}")
    except PreviewError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


@click.command(name="notice")
@click.option(
    "--run", "run_id", type=int, required=True, help="Workflow run id that built the site."
)
@click.option("--pr", type=int, default=None, help="Pull request number (adds the --pr hint).")
def ci_notice(run_id: int, pr: int | None) -> None:
    """Print a workflow log notice with the local-preview command."""
    from ._ci import render_notice_lines

    for line in render_notice_lines(run_id, pr):
        click.echo(line)


ci.add_command(ci_pr_comment)
ci.add_command(ci_notice)


# Register commands in the desired order
cli.add_command(init)
cli.add_command(build)
cli.add_command(preview)
cli.add_command(uninstall)
cli.add_command(config)
cli.add_command(ci)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--docs-dir",
    type=str,
    help="Path to documentation directory relative to project root",
)
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    help="Show method names for each class",
)
def scan(project_path: str | None, docs_dir: str | None, verbose: bool) -> None:
    """Discover package exports and preview what can be documented.

    This command analyzes your package to find public classes, functions,
    and other exports. Use this to see what's available before writing
    your reference config.

    \b
    Examples:
      great-docs scan                       # Show discovered exports
      great-docs scan --verbose             # Include method names for classes
      great-docs scan -v                    # Short form of --verbose
    """

    try:
        docs = GreatDocs(project_path=project_path)

        # Detect package name
        package_name = docs._detect_package_name()
        if not package_name:
            click.echo("Error: Could not detect package name.", err=True)
            sys.exit(1)

        # Use explicit module name from great-docs.yml (or auto-detection) when
        # available; this handles namespace packages like "firebird.base" where
        # the importable module name differs from the PyPI project name.
        module_name = docs._detect_module_name()
        if module_name:
            importable_name = module_name
        else:
            importable_name = docs._normalize_package_name(package_name)

        # Section 1: Discovery
        click.echo("─" * 50)
        click.echo("📡 Discovery")
        click.echo("─" * 50)
        click.echo(f"Package: {importable_name}\n")

        # Get discovered exports
        exports = docs._get_package_exports(importable_name)
        if not exports:
            click.echo("No exports discovered.")
            sys.exit(0)

        # Categorize exports
        categories = docs._categorize_api_objects(importable_name, exports)

        # Build sets of what's in the reference config
        reference_config = docs._config.reference
        ref_items = set()  # Items explicitly listed
        ref_classes_with_members = set()  # Classes with members: true (or default)
        ref_classes_without_members = set()  # Classes with members: false

        for section in reference_config:
            for item in section.get("contents", []):
                if isinstance(item, str):
                    ref_items.add(item)
                elif isinstance(item, dict):
                    name = item.get("name", "")
                    ref_items.add(name)
                    # Check members setting
                    members = item.get("members", True)
                    if members is False:
                        ref_classes_without_members.add(name)
                    else:
                        ref_classes_with_members.add(name)

        # Section 2: Exports
        click.echo("\n" + "─" * 50)
        click.echo(f"📦 Exports ({len(exports)} item(s))")
        click.echo("─" * 50)

        # Markers with colors
        marker_included = click.style("[x]", fg="green")
        marker_not_included = click.style("[ ]", fg="red")
        marker_class_only = click.style("[-]", fg="yellow")

        # Show class-like categories (with method details)
        _class_like_cats = [
            ("classes", "Classes"),
            ("dataclasses", "Dataclasses"),
            ("abstract_classes", "Abstract Classes"),
            ("protocols", "Protocols"),
        ]
        for cat_key, label in _class_like_cats:
            cat_items = categories.get(cat_key)
            if cat_items:
                click.echo(f"\n{label}:")
                for class_name in cat_items:
                    method_names = categories.get("class_method_names", {}).get(class_name, [])

                    # Determine class marker
                    if class_name in ref_classes_without_members:
                        class_marker = marker_class_only
                    elif class_name in ref_classes_with_members or class_name in ref_items:
                        class_marker = marker_included
                    else:
                        class_marker = marker_not_included

                    click.echo(f"• {class_marker} {class_name}")
                    for method in method_names:
                        full_method = f"{class_name}.{method}"
                        method_marker = (
                            marker_included if full_method in ref_items else marker_not_included
                        )
                        click.echo(f"    • {method_marker} {full_method}")

        # Show flat categories (simple lists)
        _flat_cats = [
            ("enums", "Enumerations"),
            ("exceptions", "Exceptions"),
            ("namedtuples", "Named Tuples"),
            ("typeddicts", "Typed Dicts"),
            ("functions", "Functions"),
            ("async_functions", "Async Functions"),
            ("constants", "Constants"),
            ("type_aliases", "Type Aliases"),
            ("other", "Other"),
        ]
        for cat_key, label in _flat_cats:
            cat_items = categories.get(cat_key)
            if cat_items:
                click.echo(f"\n{label}:")
                for name in cat_items:
                    m = marker_included if name in ref_items else marker_not_included
                    click.echo(f"• {m} {name}")

        # Section 3: Config status
        click.echo("\n" + "─" * 50)
        click.echo("📋 Reference Config")
        click.echo("─" * 50)

        if reference_config:
            click.echo(f"\n✅ Found in great-docs.yml ({len(reference_config)} section(s))")
            if verbose:
                for section in reference_config:
                    title = section.get("title", "Untitled")
                    contents = section.get("contents", [])
                    click.echo(f"    • {title}: {len(contents)} item(s)")
        else:
            click.echo("\n💡 No reference config found. Add one to great-docs.yml:")
            click.echo("   reference:")
            click.echo("     - title: Core Classes")
            click.echo("       desc: Main classes for the package")
            click.echo("       contents:")
            click.echo("         - name: MyClass")
            click.echo("           members: false     # Don't document methods")
            click.echo("         - SimpleClass        # Methods inline")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(scan)


def _freeze_info(project_root: Path, persist_dir: Path) -> None:
    """Display freeze status: project-level setting and all cached pages."""
    import json
    import re as _re
    from datetime import datetime

    from great_docs.config import Config

    cfg = Config(project_root)
    project_mode = cfg.freeze

    click.echo()
    if project_mode is not None:
        label = "auto" if project_mode == "auto" else str(project_mode)
        click.echo(f"  Project freeze: {label} (all executable pages)")
    else:
        click.echo("  Project freeze: disabled")
    click.echo(f"  Freeze cache:   {persist_dir.relative_to(project_root)}/")
    click.echo()

    # Scan source .qmd files for per-page freeze declarations
    freeze_re = _re.compile(r"^freeze:\s+(.+)$", re.MULTILINE)
    exec_freeze_re = _re.compile(r"^execute:\s*\n\s+freeze:\s+(.+)$", re.MULTILINE)

    def _extract_frontmatter(text: str) -> str:
        if not text.startswith("---"):
            return ""
        end = text.find("\n---", 3)
        if end == -1:
            return ""
        return text[3:end]

    per_page_modes: dict[str, str] = {}
    source_dirs = ["recipes", "user_guide"]
    for src_dir in source_dirs:
        src_path = project_root / src_dir
        if not src_path.is_dir():
            continue
        for qmd_file in sorted(src_path.rglob("*.qmd")):
            content = qmd_file.read_text(encoding="utf-8", errors="replace")
            frontmatter = _extract_frontmatter(content)
            match = freeze_re.search(frontmatter) or exec_freeze_re.search(frontmatter)
            if match:
                rel_path = str(qmd_file.relative_to(project_root))
                per_page_modes[rel_path] = match.group(1).strip()

    if per_page_modes:
        click.echo("  Per-page overrides:")
        for source, mode in per_page_modes.items():
            click.echo(f"    {source}  (freeze: {mode})")
        click.echo()

    # Report all cached entries from _freeze/
    if not persist_dir.is_dir():
        click.echo("  No freeze cache found yet (run a build to populate it).")
        return

    cache_entries = sorted(persist_dir.rglob("execute-results/html.json"))
    if not cache_entries:
        click.echo("  Freeze cache directory exists but contains no entries.")
        return

    click.echo(f"  {len(cache_entries)} cached page(s):")
    click.echo()

    for cache_json in cache_entries:
        rel = cache_json.relative_to(persist_dir)
        # execute-results/html.json → parent.parent is the page stem
        page_stem = str(rel.parent.parent)

        try:
            data = json.loads(cache_json.read_text(encoding="utf-8"))
            ts_match = _re.search(
                r"Executed at: ([\d-]+ [\d:]+)",
                data.get("result", {}).get("markdown", ""),
            )
            if ts_match:
                timestamp = ts_match.group(1)
            else:
                mtime = cache_json.stat().st_mtime
                timestamp = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
            click.echo(f"  ✓  {page_stem}")
            click.echo(f"       frozen at {timestamp}")
        except Exception:
            click.echo(f"  ?  {page_stem}")
            click.echo("       cache file exists but could not be parsed")

    click.echo()
    click.echo(f"  {len(cache_entries)} page(s) cached")
    click.echo()
    click.echo("  ℹ To re-freeze a stale page: great-docs freeze <page>")


@click.command()
@click.argument("pages", nargs=-1, required=False)
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--freeze-dir",
    type=str,
    default=None,
    help="Where to persist _freeze/ (default: project root '_freeze/')",
)
@click.option(
    "--clean",
    is_flag=True,
    default=False,
    help="Delete existing _freeze/ before re-executing (forces full refresh).",
)
@click.option(
    "--info",
    is_flag=True,
    default=False,
    help="Show freeze status for all pages (which are frozen, cached, stale).",
)
def freeze(
    pages: tuple[str, ...],
    project_path: str | None,
    freeze_dir: str | None,
    clean: bool,
    info: bool,
) -> None:
    """Execute specific pages and persist their freeze cache.

    Renders one or more QMD pages (always executing their code), then copies
    the resulting '_freeze/' entries back to a persistent location so they
    survive future builds.

    PAGES are paths to .qmd files relative to your project root (e.g.,
    'user_guide/benchmarks.qmd'). Quarto always executes code when rendering
    individual files, even with freeze enabled (this is how you update
    frozen outputs).

    Use '--clean' to wipe the entire '_freeze/' cache before re-executing.
    This forces a full refresh of all specified pages from scratch.

    Use '--info' to see the freeze status of all pages without rendering.

    After running, the updated '_freeze/' entries are ready to commit to
    version control.

    \b
    Examples:
      great-docs freeze user_guide/benchmarks.qmd
      great-docs freeze user_guide/benchmarks.qmd user_guide/mcmc-demo.qmd
      great-docs freeze user_guide/benchmarks.qmd --freeze-dir docs/_freeze
      great-docs freeze user_guide/benchmarks.qmd --clean
      great-docs freeze --info
    """
    import shutil
    import subprocess

    project_root = Path(project_path) if project_path else Path.cwd()
    build_dir = project_root / "great-docs"
    persist_dir = Path(freeze_dir) if freeze_dir else project_root / "_freeze"

    if info:
        _freeze_info(project_root, persist_dir)
        return

    if not pages:
        click.echo("Error: Specify at least one PAGE, or use --info.", err=True)
        sys.exit(1)

    # --clean: wipe existing cache
    if clean:
        if persist_dir.exists():
            shutil.rmtree(persist_dir)
            click.echo(f"Cleaned {persist_dir.relative_to(project_root)}/")
        build_freeze = build_dir / "_freeze"
        if build_freeze.exists():
            shutil.rmtree(build_freeze)

    # Validate pages exist
    missing = [p for p in pages if not (project_root / p).is_file()]
    if missing:
        for m in missing:
            click.echo(f"Error: Page not found: {m}", err=True)
        sys.exit(1)

    # Always re-prepare the build directory so file hashes match what a full
    # build would produce (frontmatter normalization, tag expansion, etc.).
    # This is fast (steps 1-14 only, no render) and ensures the freeze cache
    # will be valid for subsequent full builds.
    click.echo("Preparing build directory...")
    try:
        docs = GreatDocs(project_path=project_path)
        docs._prepare_for_freeze()
    except SystemExit:
        pass  # Build prep may exit but that's OK
    except Exception as e:
        click.echo(f"Error during build prep: {e}", err=True)
        sys.exit(1)

    if not (build_dir / "_quarto.yml").exists():
        click.echo("Error: Build directory is not ready. Run 'great-docs build' first.", err=True)
        sys.exit(1)

    # Render each page individually (forces execution even with freeze)
    click.echo()
    rendered: list[str] = []
    failed: list[str] = []

    for page in pages:
        # Find the page's location in the build directory
        # Pages from user_guide/ are copied into great-docs/user-guide/
        # Numeric prefixes are stripped (e.g., 24-freeze-demo.qmd -> freeze-demo.qmd)
        page_path = Path(page)
        qmd_name = page_path.name

        # Search for the file in the build directory
        candidates = list(build_dir.rglob(qmd_name))
        if not candidates:
            # Try replacing underscores with hyphens (user_guide -> user-guide)
            alt_path = str(page_path).replace("_", "-")
            candidates = list(build_dir.rglob(Path(alt_path).name))
        if not candidates:
            # Try stripping numeric prefix (e.g., 24-freeze-demo.qmd -> freeze-demo.qmd)
            import re as _re

            stripped_name = _re.sub(r"^\d+[-_]", "", qmd_name)
            if stripped_name != qmd_name:
                candidates = list(build_dir.rglob(stripped_name))
        if not candidates:
            # Try both: underscores to hyphens AND stripped prefix
            stripped_alt = _re.sub(r"^\d+[-_]", "", Path(alt_path).name)
            if stripped_alt != Path(alt_path).name:
                candidates = list(build_dir.rglob(stripped_alt))

        if not candidates:
            click.echo(f"  ✗ {page} — not found in build directory", err=True)
            click.echo(
                "    Hint: Run 'great-docs build' first to set up the build directory.",
                err=True,
            )
            failed.append(page)
            continue

        # Use the first match
        target = candidates[0]
        rel_target = target.relative_to(build_dir)

        click.echo(f"  Rendering {page} → {rel_target} ...")
        freeze_env = {**os.environ, "GD_FREEZE_ONLY": "1"}
        result = subprocess.run(
            ["quarto", "render", str(rel_target)],
            cwd=build_dir,
            capture_output=True,
            **TEXT_MODE_KWARGS,
            env=freeze_env,
        )

        if result.returncode != 0:
            click.echo(f"  ✗ {page} — render failed", err=True)
            if result.stderr:
                # Show last few lines of error
                err_lines = result.stderr.strip().splitlines()[-5:]
                for line in err_lines:
                    click.echo(f"    {line}", err=True)
            failed.append(page)
        else:
            rendered.append(page)
            click.echo(f"  ✓ {page}")

    # Copy _freeze/ from build dir to persistent location
    build_freeze = build_dir / "_freeze"
    if build_freeze.is_dir() and rendered:
        click.echo()
        click.echo(f"Persisting _freeze/ → {persist_dir.relative_to(project_root)}/")
        persist_dir.mkdir(parents=True, exist_ok=True)

        # Merge: copy only updated entries (don't wipe existing cache for other pages)
        updated_files = 0
        for item in build_freeze.iterdir():
            dest = persist_dir / item.name
            if item.is_dir():
                if dest.exists():
                    shutil.rmtree(dest)
                shutil.copytree(item, dest)
            else:
                shutil.copy2(item, dest)
            updated_files += sum(1 for _ in (dest.rglob("*") if dest.is_dir() else [dest]))

        click.echo(f"  Updated {updated_files} cached file(s)")

        # Show git status hint
        click.echo()
        click.echo("To commit the updated freeze cache:")
        click.echo(f"  git add {persist_dir.relative_to(project_root)}/")
        click.echo(f'  git commit -m "Update freeze cache for {", ".join(rendered)}"')
    elif not rendered:
        click.echo()
        click.echo("No pages rendered successfully.")
    else:
        click.echo()
        click.echo("No _freeze/ directory produced (pages may not have executable code).")

    if failed:
        click.echo()
        click.echo(f"{len(failed)} page(s) failed to render.", err=True)
        sys.exit(1)


cli.add_command(freeze)


# ---------------------------------------------------------------------------
# great-docs timing
# ---------------------------------------------------------------------------


def _format_seconds(s: float) -> str:
    """Format seconds as a human-readable duration string."""
    if s < 60:
        return f"{s:.1f}s"
    m = int(s) // 60
    sec = s - m * 60
    return f"{m}m {sec:.1f}s"


def _find_build_timing(project_path: Path, output_dir: Path | None = None) -> Path | None:
    """Locate build-timings.json in the site output directory."""
    # Explicit output-dir takes priority
    if output_dir is not None:
        candidate = output_dir / "build-timings.json"
        if candidate.exists():
            return candidate
    # Multi-version: built into great-docs/_site/
    candidate = project_path / "great-docs" / "_site" / "build-timings.json"
    if candidate.exists():
        return candidate
    # Single-version or build dir fallback
    candidate = project_path / "_site" / "build-timings.json"
    if candidate.exists():
        return candidate
    return None


def _print_timing_table(data: dict, top: int | None, version_filter: str | None) -> None:
    """Print an ASCII table from build-timings.json data."""

    build_time = data.get("build_time", "unknown")
    total = data.get("total_seconds", 0)

    click.echo()
    click.echo(f"  Build time: {build_time}")
    click.echo(f"  Total: {_format_seconds(total)}")
    click.echo()

    if "versions" in data:
        versions = data["versions"]
        if version_filter:
            if version_filter not in versions:
                click.echo(f"  Version '{version_filter}' not found.", err=True)
                click.echo(f"  Available: {', '.join(sorted(versions.keys()))}", err=True)
                sys.exit(1)
            versions = {version_filter: versions[version_filter]}

        for tag, vdata in versions.items():
            pages = vdata["pages"]
            if top:
                pages = pages[:top]
            click.echo(f"  Version: {tag} ({_format_seconds(vdata['seconds'])})")
            _print_page_table(pages)
            click.echo()
    elif "pages" in data:
        pages = data["pages"]
        if top:
            pages = pages[:top]
        _print_page_table(pages)
        click.echo()


def _print_page_table(pages: list[dict]) -> None:
    """Print a page timing table."""
    if not pages:
        click.echo("  No page timings recorded.")
        return

    # Check if any pages have freeze info
    has_frozen = any(p.get("frozen") for p in pages)

    # Compute column widths
    max_page = max(len(p["page"]) for p in pages)
    max_page = max(max_page, 4)  # minimum width for "Page" header
    time_col = 10  # width for time column
    bar_col = 20  # width for bar chart

    slowest = pages[0]["seconds"] if pages else 1

    # Header
    if has_frozen:
        header = f"  {'Page':<{max_page}}  {'Time':>{time_col}}    Bar"
        click.echo(header)
        click.echo(f"  {'─' * max_page}  {'─' * time_col}  {'─' * (bar_col + 2)}")
    else:
        header = f"  {'Page':<{max_page}}  {'Time':>{time_col}}  Bar"
        click.echo(header)
        click.echo(f"  {'─' * max_page}  {'─' * time_col}  {'─' * bar_col}")

    # Rows
    for p in pages:
        page = p["page"]
        secs = p["seconds"]
        time_str = _format_seconds(secs)
        bar_len = int((secs / slowest) * bar_col) if slowest > 0 else 0
        bar = "█" * bar_len
        if has_frozen:
            marker = "❄" if p.get("frozen") else " "
            click.echo(f"  {page:<{max_page}}  {time_str:>{time_col}} {marker} {bar}")
        else:
            click.echo(f"  {page:<{max_page}}  {time_str:>{time_col}}  {bar}")

    if has_frozen:
        click.echo()
        click.echo("  ❄ = served from freeze cache")


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--top",
    type=int,
    default=None,
    help="Show only the N slowest pages.",
)
@click.option(
    "--version",
    "version_filter",
    type=str,
    default=None,
    help="Show timings for a specific version only (multi-version builds).",
)
@click.option(
    "--json",
    "output_json",
    is_flag=True,
    help="Output raw JSON instead of a table.",
)
@click.option(
    "--output-dir",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    default=None,
    help="Path to the build output directory (if different from default _site).",
)
def timings(project_path, top, version_filter, output_json, output_dir):
    """Show page-level build timings from the last build.

    Reads the build-timings.json artifact generated during 'great-docs build' and
    displays per-page render durations as a sorted table. Pages are listed
    slowest-first to help identify bottlenecks.

    Run 'great-docs build' first to generate the timing data.

    \b
    Examples:
      great-docs timings
      great-docs timings --top 10
      great-docs timings --version 0.10
      great-docs timings --output-dir ./public
      great-docs timings --json
    """
    import json

    project_root = Path(project_path) if project_path else Path.cwd()
    out_dir = Path(output_dir) if output_dir else None
    timing_path = _find_build_timing(project_root, output_dir=out_dir)

    if not timing_path:
        click.echo("No build-timings.json found.", err=True)
        click.echo("Run 'great-docs build' first to generate timing data.", err=True)
        sys.exit(1)

    data = json.loads(timing_path.read_text())

    if output_json:
        click.echo(json.dumps(data, indent=2))
        return

    _print_timing_table(data, top=top, version_filter=version_filter)


cli.add_command(timings)


@click.command(name="setup-github-pages")
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--main-branch",
    type=str,
    default="main",
    help="Main branch name for deployment (default: main)",
)
@click.option(
    "--python-version",
    type=str,
    default=None,
    help="Python version for CI (default: auto-detect from pyproject.toml, or 3.11)",
)
@click.option(
    "--package-manager",
    type=click.Choice(["auto", "pip", "uv", "poetry"]),
    default="auto",
    help="Package manager for installing dependencies (default: auto-detect)",
)
@click.option(
    "--force",
    is_flag=True,
    help="Overwrite existing workflow file without prompting",
)
@click.option(
    "--install-from-main",
    is_flag=True,
    help="Install Great Docs from GitHub main branch instead of PyPI release",
)
def setup_github_pages(
    project_path: str | None,
    main_branch: str,
    python_version: str | None,
    package_manager: str,
    force: bool,
    install_from_main: bool,
) -> None:
    """Set up automatic deployment to GitHub Pages.

    This command creates a GitHub Actions workflow that automatically builds
    and deploys your documentation when you push to the main branch.

    \b
    The workflow will:
    • Build docs on every push and pull request
    • Deploy to GitHub Pages on main branch pushes
    • Use Quarto's official GitHub Action for reliable builds
    • Install dev dependencies (auto-detected from your package manager)

    The Python version is automatically detected from your pyproject.toml's
    `requires-python` field. Use '--python-version' to override.

    The package manager is auto-detected by checking for lock files:
    • uv.lock -> uses uv (installs dev dependencies automatically)
    • poetry.lock -> uses poetry (installs with dev dependencies)
    • Otherwise -> uses pip with optional extras like [dev,docs]

    After running this command, commit the workflow file and enable GitHub
    Pages in your repository settings (Settings -> Pages ->
    Source: GitHub Actions).

    \b
    Examples:
      great-docs setup-github-pages                     # Auto-detect everything
      great-docs setup-github-pages --main-branch dev   # Deploy from 'dev' branch
      great-docs setup-github-pages --python-version 3.12
      great-docs setup-github-pages --package-manager uv
      great-docs setup-github-pages --force             # Overwrite existing workflow
      great-docs setup-github-pages --install-from-main # Use GitHub main branch
    """

    try:
        # Determine project root
        project_root = Path(project_path) if project_path else Path.cwd()

        # Auto-detect Python version if not specified
        # Great Docs requires Python 3.11+, so we enforce that as the floor
        min_great_docs_version = (3, 11)
        if python_version is None:
            detected_version = _detect_python_version_from_pyproject(project_root)
            if detected_version:
                detected_tuple = tuple(int(x) for x in detected_version.split("."))
                if detected_tuple < min_great_docs_version:
                    python_version = f"{min_great_docs_version[0]}.{min_great_docs_version[1]}"
                    click.echo(
                        f"📦 Project requires Python {detected_version}, "
                        f"but Great Docs needs >={python_version}; using {python_version}"
                    )
                else:
                    python_version = detected_version
                    click.echo(f"📦 Detected Python {python_version} from pyproject.toml")
            else:
                python_version = "3.12"
                click.echo("📦 Using default Python 3.12 (no requires-python found)")

        # Auto-detect package manager if not specified
        if package_manager == "auto":
            package_manager = _detect_package_manager(project_root)
            if package_manager == "uv":
                click.echo("🔧 Detected uv (found uv.lock)")
            elif package_manager == "poetry":
                click.echo("🔧 Detected poetry (found poetry.lock)")
            else:
                click.echo("🔧 Using pip (no uv.lock or poetry.lock found)")

        # Determine great-docs install source
        if install_from_main:
            great_docs_install = "git+https://github.com/posit-dev/great-docs.git"
            click.echo("📥 Will install Great Docs from GitHub main branch")
        else:
            great_docs_install = "great-docs"
            click.echo("📥 Will install Great Docs from PyPI (latest release)")

        # Generate install commands based on package manager
        if package_manager == "uv":
            install_commands = f"""\
          python -m pip install uv
          uv sync
          uv pip install {great_docs_install}"""
            build_command = "uv run great-docs build"
        elif package_manager == "poetry":
            install_commands = f"""\
          python -m pip install poetry
          poetry install
          poetry run pip install {great_docs_install}"""
            build_command = "poetry run great-docs build"
        else:
            # pip: try to detect optional dependencies
            optional_deps = _detect_optional_dependencies(project_root)
            if optional_deps:
                extras = ",".join(optional_deps)
                click.echo(f"📋 Found optional dependencies: {extras}")
                install_commands = f"""\
          python -m pip install --upgrade pip
          python -m pip install -e ".[{extras}]"
          python -m pip install {great_docs_install}"""
            else:
                install_commands = f"""\
          python -m pip install --upgrade pip
          python -m pip install -e .
          python -m pip install {great_docs_install}"""
            build_command = "great-docs build"

        # Create .github/workflows directory
        workflow_dir = project_root / ".github" / "workflows"
        workflow_file = workflow_dir / "docs.yml"

        # Check if workflow file already exists
        if workflow_file.exists() and not force:
            if not click.confirm(
                f"⚠️  Workflow file already exists at {workflow_file.relative_to(project_root)}\n"
                "   Overwrite it?",
                default=False,
            ):
                click.echo("❌ Aborted. Use --force to overwrite without prompting.")
                sys.exit(1)

        # Create directory structure
        workflow_dir.mkdir(parents=True, exist_ok=True)

        # Load template
        try:
            # For Python 3.9+
            from importlib.resources import files

            template_file = files("great_docs").joinpath("assets/github-workflow-template.yml")
            template_content = template_file.read_text()
        except (ImportError, AttributeError):
            # For Python 3.8 or earlier
            from importlib_resources import files

            template_file = files("great_docs").joinpath("assets/github-workflow-template.yml")
            template_content = template_file.read_text()

        # Replace placeholders (using replace() to handle linter-formatted templates)
        workflow_content = template_content.replace("{ main_branch }", main_branch)
        workflow_content = workflow_content.replace("{main_branch}", main_branch)
        workflow_content = workflow_content.replace("{ python_version }", python_version)
        workflow_content = workflow_content.replace("{python_version}", python_version)
        workflow_content = workflow_content.replace("{install_commands}", install_commands)
        workflow_content = workflow_content.replace("{build_command}", build_command)

        # Write workflow file
        workflow_file.write_text(workflow_content)

        click.echo(
            f"✅ Created GitHub Actions workflow at {workflow_file.relative_to(project_root)}"
        )
        click.echo()
        click.echo("📋 Next steps:")
        click.echo("   1. Commit and push the workflow file to your repository")
        click.echo("   2. Go to your repository Settings → Pages")
        click.echo("   3. Set Source to 'GitHub Actions' (or 'gh-pages branch' if using that)")
        click.echo(f"   4. Push changes to '{main_branch}' branch to trigger deployment")
        click.echo()
        click.echo("💡 The workflow will:")
        click.echo(f"   • Build docs on every push to '{main_branch}' and pull requests")
        click.echo("   • Automatically deploy to GitHub Pages on main branch")
        click.echo("   • Comment on pull requests with a 'great-docs preview' command")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


# Register commands in the desired order
cli.add_command(setup_github_pages)


@click.command(name="check-links")
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--source-only",
    is_flag=True,
    help="Only check links in Python source files",
)
@click.option(
    "--docs-only",
    is_flag=True,
    help="Only check links in documentation files",
)
@click.option(
    "--timeout",
    type=float,
    default=10.0,
    help="Timeout in seconds for each HTTP request (default: 10)",
)
@click.option(
    "--ignore",
    "-i",
    multiple=True,
    help="URL pattern to ignore (can be used multiple times)",
)
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    help="Show detailed progress for each URL checked",
)
@click.option(
    "--json-output",
    is_flag=True,
    help="Output results as JSON",
)
def check_links(
    project_path: str | None,
    source_only: bool,
    docs_only: bool,
    timeout: int,
    ignore: tuple[str, ...],
    verbose: bool,
    json_output: bool,
) -> None:
    """Check for broken links in source code and documentation.

    This command scans Python source files and documentation ('.qmd', '.md')
    for URLs and checks their HTTP status. It reports broken links (404s)
    and warns about redirects.

    \b
    Default ignore patterns include:
    • localhost and 127.0.0.1 URLs
    • example.com, example.org, yoursite.com URLs
    • Placeholder URLs with brackets like [username]

    \b
    Examples:
      great-docs check-links                        # Check all links
      great-docs check-links --verbose              # Show progress for each URL
      great-docs check-links --docs-only            # Only check documentation
      great-docs check-links --source-only          # Only check source code
      great-docs check-links -i "github.com/.*#"    # Ignore GitHub anchor links
      great-docs check-links --timeout 5            # Use 5 second timeout
      great-docs check-links --json-output          # Output as JSON
    """
    import json as json_module

    try:
        docs = GreatDocs(project_path=project_path)

        # Determine what to scan
        include_source = not docs_only
        include_docs = not source_only

        # Build ignore patterns list
        ignore_patterns = list(ignore) if ignore else []
        # Add default ignore patterns
        default_ignores = [
            r"localhost",
            r"127\.0\.0\.1",
            r"0\.0\.0\.0",
            r"example\.com",
            r"example\.org",
            r"example\.net",
            r"\[",  # URLs with brackets (placeholders like [username])
            r"yoursite\.com",
            r"your-package",
            r"YOUR-USERNAME",
            r"\.git(@|$)",  # Git URLs (pip install git+...) with optional branch/tag
        ]
        ignore_patterns.extend(default_ignores)

        if not json_output:
            click.echo("🔗 Checking links...")
            if not include_source:
                click.echo("   (documentation files only)")
            elif not include_docs:
                click.echo("   (source files only)")

        results = docs.check_links(
            include_source=include_source,
            include_docs=include_docs,
            timeout=timeout,
            ignore_patterns=ignore_patterns,
            verbose=verbose,
        )

        if json_output:
            # Output as JSON
            click.echo(json_module.dumps(results, indent=2))
            sys.exit(1 if results["broken"] else 0)

        # Print summary
        click.echo("\n" + "=" * 60)
        click.echo("📊 Link Check Summary")
        click.echo("=" * 60)

        total_checked = results["total"] - len(results["skipped"])
        click.echo(f"\n   Total URLs found: {results['total']}")
        click.echo(f"   URLs checked: {total_checked}")
        click.echo(f"   URLs skipped: {len(results['skipped'])}")

        click.echo(f"\n   ✅ OK: {len(results['ok'])}")
        click.echo(f"   ↪️  Redirects: {len(results['redirects'])}")
        click.echo(f"   ❌ Broken: {len(results['broken'])}")

        # Show broken links
        if results["broken"]:
            click.echo("\n" + "-" * 60)
            click.echo("❌ Broken Links:")
            click.echo("-" * 60)
            for item in results["broken"]:
                status = item["status"] or "N/A"
                click.echo(f"\n   [{status}] {item['url']}")
                click.echo(f"   Error: {item['error']}")
                click.echo("   Found in:")
                for f in item["files"]:
                    click.echo(f"     • {f}")

        # Show redirects
        if results["redirects"]:
            click.echo("\n" + "-" * 60)
            click.echo("↪️  Redirects (consider updating):")
            click.echo("-" * 60)
            for item in results["redirects"]:
                click.echo(f"\n   [{item['status']}] {item['url']}")
                click.echo(f"   → {item['location']}")
                click.echo("   Found in:")
                for f in item["files"]:
                    click.echo(f"     • {f}")

        # Exit with error code if broken links found
        if results["broken"]:
            click.echo("\n⚠️  Found broken links. Please fix them before deployment.")
            sys.exit(1)
        else:
            click.echo("\n✅ All links are valid!")
            sys.exit(0)

    except ImportError as e:
        click.echo(f"Error: {e}", err=True)
        click.echo("\nInstall the requests package with: pip install requests", err=True)
        sys.exit(1)
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(check_links)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--max-releases",
    type=int,
    default=None,
    help="Maximum number of releases to include (default: from config or 50)",
)
def changelog(project_path: str | None, max_releases: int | None) -> None:
    """Generate a Changelog page from GitHub Releases.

    Fetches published releases from the GitHub API and renders them as a
    'changelog.qmd' page in the build directory. The page is also linked in
    the navbar automatically.

    \b
    Requires the project to have a GitHub repository URL in 'pyproject.toml'.
    Set 'GITHUB_TOKEN' or 'GH_TOKEN' to avoid API rate limits.
    """
    try:
        docs = GreatDocs(project_path=project_path)

        # Override max_releases in config if provided
        if max_releases is not None:
            docs._config._config.setdefault("changelog", {})["max_releases"] = max_releases

        owner, repo, _base_url = docs._get_github_repo_info()
        if not owner or not repo:
            click.echo(
                "Error: No GitHub repository URL found in pyproject.toml. "
                "Add a [project.urls] entry like:\n\n"
                '  Repository = "https://github.com/owner/repo"',
                err=True,
            )
            sys.exit(1)

        result = docs._generate_changelog_page()
        if result:
            docs._add_changelog_to_navbar()
            click.echo(f"✅ Changelog generated: {docs.project_path / result}")
        else:
            click.echo("No published releases found on GitHub.")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(changelog)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--docs-dir",
    type=str,
    help="Path to documentation directory relative to project root",
)
@click.option(
    "--include-docstrings",
    is_flag=True,
    help="Also check Python docstrings",
)
@click.option(
    "--spelling-only",
    is_flag=True,
    help="Only check spelling (SpellCheck rule)",
)
@click.option(
    "--grammar-only",
    is_flag=True,
    help="Exclude spelling, check grammar/style only",
)
@click.option(
    "--only",
    "only_rules",
    type=str,
    help="Only run these rules (comma-separated)",
)
@click.option(
    "--ignore",
    "ignore_rules",
    type=str,
    help="Skip these rules (comma-separated)",
)
@click.option(
    "-d",
    "--dictionary",
    "custom_words",
    multiple=True,
    help="Additional word(s) to consider correct (can be used multiple times)",
)
@click.option(
    "--dictionary-file",
    type=click.Path(exists=True),
    help="Path to file with custom words (one per line)",
)
@click.option(
    "--dialect",
    type=click.Choice(["us", "uk", "au", "in", "ca"], case_sensitive=False),
    default="us",
    help="English dialect (default: us)",
)
@click.option(
    "-v",
    "--verbose",
    is_flag=True,
    help="Show detailed progress for each file checked",
)
@click.option(
    "--json-output",
    is_flag=True,
    help="Output results as JSON for CI",
)
@click.option(
    "--compact",
    is_flag=True,
    help="One line per issue (GCC-style output)",
)
@click.option(
    "--max-issues",
    type=int,
    default=None,
    help="Exit with error if more than N issues found",
)
@click.option(
    "--strict",
    is_flag=True,
    help="Disable smart defaults (check everything, no builtin dictionary)",
)
@click.option(
    "--no-builtin-dictionary",
    is_flag=True,
    help="Don't add built-in technical terms to dictionary",
)
@click.argument("files", nargs=-1, type=click.Path(exists=True))
def proofread(
    project_path,
    docs_dir,
    include_docstrings,
    spelling_only,
    grammar_only,
    only_rules,
    ignore_rules,
    custom_words,
    dictionary_file,
    dialect,
    verbose,
    json_output,
    compact,
    max_issues,
    strict,
    no_builtin_dictionary,
    files,
):
    """Check spelling and grammar in documentation files using Harper.

    Harper is a fast, privacy-first grammar checker that runs locally.
    It checks spelling, grammar, punctuation, and style in a single pass.

    \b
    By default, checks all documentation files (.qmd, .md) in the project.
    Uses smart defaults to reduce noise in technical documentation:
      - Ignores formatting rules that conflict with code/YAML (unless '--strict')
      - Includes a built-in dictionary of technical terms (unless '--no-builtin-dictionary')

    \b
    Examples:
      great-docs proofread                         # Check all docs (smart defaults)
      great-docs proofread --strict                # Check everything (no smart defaults)
      great-docs proofread --spelling-only         # Just spelling
      great-docs proofread --dialect=uk            # UK English
      great-docs proofread -d griffe -d quartodoc  # Add custom words
      great-docs proofread --json-output           # JSON output for CI
      great-docs proofread --ignore=SpellCheck     # Skip specific rules
      great-docs proofread README.md user_guide/*.qmd  # Specific files

    \b
    Requires harper-cli to be installed:
      brew install harper      # macOS
      cargo install harper-cli # any platform
    """
    import json as json_module
    import tempfile
    from collections import defaultdict

    from ._harper import (
        HarperError,
        HarperNotFoundError,
        check_harper_available,
        get_builtin_dictionary,
        get_default_ignore_rules,
        run_harper,
        run_harper_on_text,
    )

    try:
        # Check if Harper is available
        available, harper_info = check_harper_available()
        if not available:
            click.echo(f"Error: {harper_info}", err=True)
            sys.exit(3)

        if verbose and not json_output and not compact:
            click.echo(f"\n🔍 Proofreading with {harper_info}...\n")
            if not strict:
                click.echo("Using smart defaults for technical docs. Use --strict to disable.\n")

        # Determine files to check
        docs = GreatDocs(project_path=project_path)
        files_to_check: list[Path] = []

        if files:
            # User specified files
            files_to_check = [Path(f) for f in files]
        else:
            # Auto-discover documentation files
            user_guide_dir = docs.project_root / "user_guide"
            if user_guide_dir.exists():
                files_to_check.extend(user_guide_dir.rglob("*.qmd"))
                files_to_check.extend(user_guide_dir.rglob("*.md"))

            # Check README
            readme = docs.project_root / "README.md"
            if readme.exists():
                files_to_check.append(readme)

            # Check recipes if they exist
            recipes_dir = docs.project_root / "recipes"
            if recipes_dir.exists():
                files_to_check.extend(recipes_dir.rglob("*.qmd"))
                files_to_check.extend(recipes_dir.rglob("*.md"))

        if not files_to_check:
            click.echo("No documentation files found to check.", err=True)
            sys.exit(0)

        # Build custom dictionary file
        # Start with builtin dictionary unless disabled
        dict_path = None
        words = []

        if not strict and not no_builtin_dictionary:
            words.extend(get_builtin_dictionary())

        # Add user-provided words
        if custom_words:
            words.extend(custom_words)

        # Load from dictionary file if provided
        if dictionary_file:
            try:
                with open(dictionary_file, "r", encoding="utf-8") as f:
                    for line in f:
                        word = line.strip()
                        if word and not word.startswith("#"):
                            words.append(word)
            except Exception as e:
                click.echo(f"Warning: Could not read dictionary file: {e}", err=True)

        if words:
            # Create temporary dictionary file
            tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
            tmp.write("\n".join(words))
            tmp.close()
            dict_path = tmp.name

        # Build rule filters
        only = None
        ignore = None

        # Start with default ignores for technical docs (unless --strict)
        if not strict:
            ignore = get_default_ignore_rules()

        if spelling_only:
            only = ["SpellCheck"]
        elif grammar_only:
            ignore = (ignore or []) + ["SpellCheck"]

        if only_rules:
            only = only_rules.split(",") if only is None else only + only_rules.split(",")

        if ignore_rules:
            ignore = ignore_rules.split(",") if ignore is None else ignore + ignore_rules.split(",")

        # Separate files by type (Harper doesn't recognize .qmd)
        md_files = [f for f in files_to_check if f.suffix == ".md"]
        qmd_files = [f for f in files_to_check if f.suffix == ".qmd"]
        py_files = [f for f in files_to_check if f.suffix == ".py"]

        all_results = []

        # Check .md files directly
        if md_files:
            if verbose and not json_output and not compact:
                click.echo(f"Checking {len(md_files)} Markdown file(s)...\n")

            results = run_harper(
                md_files,
                dialect=dialect,
                user_dict_path=dict_path,
                ignore_rules=ignore,
                only_rules=only,
            )
            all_results.extend(results)

        # Check .qmd files via stdin (Harper doesn't recognize extension)
        # We extract prose only (skip code blocks) to avoid false positives
        for qmd_file in qmd_files:
            if verbose and not json_output and not compact:
                click.echo(f"Checking {qmd_file.name}...")

            try:
                from ._harper import extract_prose_from_markdown

                content = qmd_file.read_text(encoding="utf-8")

                # Extract prose sections, skipping fenced code blocks and frontmatter
                prose_content, line_mapping = extract_prose_from_markdown(content)

                lints = run_harper_on_text(
                    prose_content,
                    dialect=dialect,
                    user_dict_path=dict_path,
                    ignore_rules=ignore,
                    only_rules=only,
                )

                # Convert to file result with proper path and remap line numbers
                from ._harper import HarperFileResult

                rel_path = str(qmd_file.relative_to(docs.project_root))
                for lint in lints:
                    lint.file = rel_path
                    # Remap line number from prose content back to original file
                    if lint.line in line_mapping:
                        lint.line = line_mapping[lint.line]

                all_results.append(
                    HarperFileResult(
                        file=rel_path,
                        lint_count=len(lints),
                        lints=lints,
                        error=None,
                    )
                )
            except Exception as e:
                from ._harper import HarperFileResult

                rel_path = str(qmd_file.relative_to(docs.project_root))
                all_results.append(
                    HarperFileResult(
                        file=rel_path,
                        lint_count=0,
                        lints=[],
                        error=str(e),
                    )
                )

        # Check Python files if requested
        if include_docstrings and py_files:
            if verbose and not json_output and not compact:
                click.echo(f"\nChecking {len(py_files)} Python file(s) for docstrings...\n")

            results = run_harper(
                py_files,
                dialect=dialect,
                user_dict_path=dict_path,
                ignore_rules=ignore,
                only_rules=only,
            )
            all_results.extend(results)

        # Clean up temp dictionary file
        if dict_path:
            try:
                Path(dict_path).unlink()
            except Exception:
                pass

        # Aggregate results
        total_issues = sum(r.lint_count for r in all_results)
        files_with_issues = sum(1 for r in all_results if r.lint_count > 0)

        # Group by kind and rule
        by_kind = defaultdict(int)
        by_rule = defaultdict(int)
        all_lints = []

        for result in all_results:
            for lint in result.lints:
                by_kind[lint.kind] += 1
                by_rule[lint.rule] += 1
                all_lints.append(lint)

        # Output results
        if json_output:
            output = {
                "version": "1.0.0",
                "harper_version": harper_info.split()[-1] if harper_info else "unknown",
                "dialect": dialect,
                "files_checked": len(files_to_check),
                "total_issues": total_issues,
                "summary": {
                    "by_kind": dict(by_kind),
                    "by_rule": dict(by_rule),
                },
                "issues": [
                    {
                        "file": lint.file,
                        "line": lint.line,
                        "column": lint.column,
                        "kind": lint.kind,
                        "rule": lint.rule,
                        "message": lint.message,
                        "matched_text": lint.matched_text,
                        "suggestions": lint.suggestions,
                        "priority": lint.priority,
                    }
                    for lint in all_lints
                ],
            }
            click.echo(json_module.dumps(output, indent=2))

        elif compact:
            # GCC-style output: file:line:col: kind::rule: message
            for lint in all_lints:
                click.echo(
                    f"{lint.file}:{lint.line}:{lint.column}: "
                    f"{lint.kind}::{lint.rule}: {lint.message}"
                )

        else:
            # Human-readable output
            if all_results:
                for result in all_results:
                    if result.lint_count > 0:
                        click.echo(f"\n📄 {result.file} ({result.lint_count} issue(s))")
                        for lint in result.lints:
                            # Clean up suggestion format
                            suggestion = ""
                            if lint.suggestions:
                                first_sugg = lint.suggestions[0]
                                # Harper suggestions often have "Replace with: " prefix
                                if "Replace with:" in first_sugg:
                                    first_sugg = first_sugg.replace("Replace with:", "→").strip()
                                    first_sugg = first_sugg.strip('"').strip('"').strip('"')
                                suggestion = f" {first_sugg}"

                            click.echo(
                                f"   Line {lint.line}, Col {lint.column} "
                                f'[{lint.kind}] "{lint.matched_text}"{suggestion}'
                            )
                            if verbose:
                                click.echo(f"      {lint.message}")
                    elif verbose and result.error is None:
                        click.echo(f"✅ {result.file}")

            # Print summary
            click.echo("\n" + "═" * 66)
            click.echo("📊 Proofread Results")
            click.echo("═" * 66)

            click.echo(f"\n   Files checked: {len(files_to_check)}")
            click.echo(f"   Issues found: {total_issues}")

            if by_kind:
                click.echo("\n   By category:")
                for kind, count in sorted(by_kind.items(), key=lambda x: -x[1]):
                    click.echo(f"     {kind}: {count}")

            if verbose and by_rule:
                click.echo("\n   By rule:")
                for rule, count in sorted(by_rule.items(), key=lambda x: -x[1]):
                    click.echo(f"     {rule}: {count}")

            click.echo("\n" + "─" * 66)

            if total_issues > 0:
                click.echo("💡 Tips:")
                click.echo("   • Add custom words: -d word1 -d word2")
                click.echo("   • Create dictionary: .great-docs-dictionary (one word per line)")
                click.echo("   • Disable a rule: --ignore SentenceCapitalization")
                click.echo("   • List all rules: harper-cli config")
                click.echo("─" * 66)

        # Determine exit code
        if max_issues is not None and total_issues > max_issues:
            if not json_output and not compact:
                click.echo(
                    f"\n⚠️  Found {total_issues} issue(s), exceeds threshold of {max_issues}."
                )
            sys.exit(1)
        elif total_issues > 0:
            if not json_output and not compact:
                click.echo(f"\n⚠️  Found {total_issues} issue(s).")
            sys.exit(1)
        else:
            if not json_output and not compact:
                click.echo("\n✅ No issues found!")
            sys.exit(0)

    except HarperNotFoundError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(3)
    except HarperError as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(2)
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(proofread)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--fix",
    is_flag=True,
    help="Attempt to fix some issues automatically (e.g., generate missing files)",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output results as JSON for CI integration",
)
def seo(project_path: str | None, fix: bool, json_output: bool) -> None:
    """Audit SEO health of your documentation site.

    Checks for common SEO issues and provides recommendations for improvement.
    Run this after building your site with 'great-docs build'.

    \b
    Checks performed:
      • sitemap.xml presence and validity
      • robots.txt presence and configuration
      • Canonical URLs on all pages
      • Meta descriptions on pages
      • JSON-LD structured data
      • Page titles with site name
      • Missing alt text on images
      • Broken internal links (basic check)

    \b
    Examples:
      great-docs seo                        # Audit SEO health
      great-docs seo --fix                  # Fix issues where possible
      great-docs seo --json                 # JSON output for CI
    """
    import json
    import xml.etree.ElementTree as ET

    try:
        docs = GreatDocs(project_path=project_path)
        site_dir = docs.project_path / "_site"

        if not site_dir.exists():
            click.echo("Error: Site not built. Run 'great-docs build' first.", err=True)
            sys.exit(1)

        issues = []
        warnings = []
        info = []

        # ── Check sitemap.xml ────────────────────────────────────────────
        sitemap_path = site_dir / "sitemap.xml"
        if sitemap_path.exists():
            try:
                tree = ET.parse(sitemap_path)
                root = tree.getroot()
                # Count URLs in sitemap
                ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
                urls = root.findall(".//sm:url", ns)
                if urls:
                    info.append(f"✅ sitemap.xml: {len(urls)} URLs indexed")
                else:
                    warnings.append("⚠️  sitemap.xml is empty (no URLs)")
            except ET.ParseError as e:
                issues.append(f"❌ sitemap.xml is malformed: {e}")
        else:
            issues.append("❌ sitemap.xml not found")
            if fix:
                docs._generate_sitemap_xml()
                info.append("   → Generated sitemap.xml")

        # ── Check robots.txt ─────────────────────────────────────────────
        robots_path = site_dir / "robots.txt"
        if robots_path.exists():
            robots_content = robots_path.read_text()
            if "Sitemap:" in robots_content:
                info.append("✅ robots.txt: includes sitemap reference")
            else:
                warnings.append("⚠️  robots.txt: missing sitemap reference")
            if "User-agent:" in robots_content:
                info.append("✅ robots.txt: has user-agent rules")
        else:
            issues.append("❌ robots.txt not found")
            if fix:
                docs._generate_robots_txt()
                info.append("   → Generated robots.txt")

        # ── Check HTML pages ─────────────────────────────────────────────
        html_files = list(site_dir.rglob("*.html"))
        pages_checked = 0
        pages_missing_canonical = 0
        pages_missing_description = 0
        pages_missing_title_template = 0
        pages_with_json_ld = 0
        images_missing_alt = 0

        canonical_base = docs._get_canonical_base_url()

        for html_file in html_files:
            rel_path = html_file.relative_to(site_dir).as_posix()

            # Skip internal files
            if rel_path.startswith("_") or rel_path.startswith("."):
                continue

            pages_checked += 1
            content = html_file.read_text(encoding="utf-8", errors="ignore")

            # Check canonical URL
            if 'rel="canonical"' not in content:
                pages_missing_canonical += 1

            # Check meta description
            if not re.search(r'<meta\s+name="description"', content):
                pages_missing_description += 1

            # Check title template (should have | or -)
            title_match = re.search(r"<title>([^<]+)</title>", content)
            if title_match:
                title = title_match.group(1)
                if " | " not in title and " - " not in title:
                    pages_missing_title_template += 1

            # Check JSON-LD
            if "application/ld+json" in content:
                pages_with_json_ld += 1

            # Check images for alt text
            for img_match in re.finditer(r"<img\s+[^>]*>", content):
                img_tag = img_match.group(0)
                if 'alt="' not in img_tag and "alt='" not in img_tag:
                    images_missing_alt += 1

        # Report HTML page analysis
        info.append(f"✅ Analyzed {pages_checked} HTML pages")

        if pages_missing_canonical > 0:
            if canonical_base:
                issues.append(f"❌ {pages_missing_canonical} pages missing canonical URLs")
            else:
                warnings.append(
                    f"⚠️  {pages_missing_canonical} pages missing canonical URLs "
                    "(set seo.canonical.base_url)"
                )
        else:
            info.append("✅ All pages have canonical URLs")

        if pages_missing_description > 0:
            warnings.append(f"⚠️  {pages_missing_description} pages missing meta descriptions")
        else:
            info.append("✅ All pages have meta descriptions")

        if pages_missing_title_template > 0:
            warnings.append(
                f"⚠️  {pages_missing_title_template} pages have plain titles "
                "(consider adding site name)"
            )

        if pages_with_json_ld > 0:
            info.append(f"✅ {pages_with_json_ld} pages have JSON-LD structured data")
        else:
            warnings.append("⚠️  No pages have JSON-LD structured data")

        if images_missing_alt > 0:
            warnings.append(f"⚠️  {images_missing_alt} images missing alt text")
        elif pages_checked > 0:
            info.append("✅ All images have alt text")

        # ── Output results ───────────────────────────────────────────────
        if json_output:
            result = {
                "status": "fail" if issues else ("warn" if warnings else "pass"),
                "pages_checked": pages_checked,
                "issues": issues,
                "warnings": warnings,
                "info": info,
            }
            click.echo(json.dumps(result, indent=2))
        else:
            click.echo("\n" + "═" * 60)
            click.echo("📊 SEO Audit Results")
            click.echo("═" * 60)

            if info:
                click.echo("\n" + "\n".join(info))

            if warnings:
                click.echo("\n" + "\n".join(warnings))

            if issues:
                click.echo("\n" + "\n".join(issues))

            click.echo("\n" + "─" * 60)
            if issues:
                click.echo(f"❌ {len(issues)} issue(s) found")
                sys.exit(1)
            elif warnings:
                click.echo(f"⚠️  {len(warnings)} warning(s)")
            else:
                click.echo("✅ All SEO checks passed!")

    except Exception as e:
        if json_output:
            click.echo(json.dumps({"status": "error", "error": str(e)}))
        else:
            click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(seo)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--check",
    "checks",
    multiple=True,
    type=click.Choice(["docstrings", "cross-refs", "style", "directives", "stale-versions"]),
    help="Run only specific checks (can be repeated). Default: all checks.",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output results as JSON for CI integration",
)
def lint(project_path: str | None, checks: tuple[str, ...], json_output: bool) -> None:
    """Lint documentation quality for your package.

    Analyzes your package's public API for documentation issues including
    missing docstrings, broken cross-references, inconsistent formatting,
    malformed directives, and stale version annotations.

    \b
    Checks performed:
      • missing-docstring    Public exports or methods without docstrings
      • broken-xref          '%seealso' references to unknown symbols
      • style-mismatch       Docstrings not matching configured style (numpy/google/sphinx)
      • unknown-directive    Unrecognized '%directive' names
      • stale-badge          Version badges far behind latest release
      • stale-callout        Version callouts that are very old
      • stale-upcoming       'upcoming:' frontmatter for already-released versions

    \b
    Examples:
      great-docs lint                                # Run all checks
      great-docs lint --check stale-versions         # Only check for stale annotations
      great-docs lint --check docstrings             # Only check for missing docstrings
      great-docs lint --check cross-refs --check style
      great-docs lint --json                         # JSON output for CI
      great-docs lint --json | jq '.issues[] | select(.severity == "error")'
    """
    import json
    import textwrap

    from ._lint import run_lint

    try:
        project_root = Path(project_path) if project_path else Path.cwd()
        check_set = set(checks) if checks else None

        result = run_lint(project_root, checks=check_set, quiet=json_output)

        if json_output:
            click.echo(json.dumps(result.to_dict(), indent=2))
        else:
            click.echo("\n" + "═" * 60)
            click.echo("📋 Documentation Lint Results")
            click.echo("═" * 60)

            if result.package_name:
                click.echo(f"\nPackage: {result.package_name}")
                click.echo(f"Exports checked: {result.exports_count}")

            if not result.issues:
                click.echo("\n✅ All documentation checks passed!")
            else:
                # Group issues by check type
                by_check: dict[str, list] = {}
                for issue in result.issues:
                    by_check.setdefault(issue.check, []).append(issue)

                for check_name, issues in sorted(by_check.items()):
                    click.echo(f"\n{'─' * 60}")

                    # Count severities
                    errs = sum(1 for i in issues if i.severity == "error")
                    warns = sum(1 for i in issues if i.severity == "warning")
                    infos = sum(1 for i in issues if i.severity == "info")
                    label_parts = []
                    if errs:
                        label_parts.append(f"{errs} error(s)")
                    if warns:
                        label_parts.append(f"{warns} warning(s)")
                    if infos:
                        label_parts.append(f"{infos} info")
                    label = ", ".join(label_parts)

                    click.echo(f"  {check_name}  [{label}]")
                    click.echo(f"{'─' * 60}")

                    for issue in issues:
                        if issue.severity == "error":
                            icon = "❌"
                        elif issue.severity == "info":
                            icon = "ℹ️ "
                        else:
                            icon = "⚠️ "
                        symbol_str = f"  {issue.symbol}" if issue.symbol else ""
                        click.echo(f"  {icon}{symbol_str}")
                        # Wrap long messages indented below the path
                        msg_indent = "        "
                        lines = textwrap.wrap(issue.message, width=56)
                        for line in lines:
                            click.echo(f"{msg_indent}{line}")

                click.echo(f"\n{'─' * 60}")
                n_errors = len(result.errors)
                n_warnings = len(result.warnings)
                if n_errors:
                    click.echo(f"❌ {n_errors} error(s), {n_warnings} warning(s)")
                else:
                    click.echo(f"⚠️  {n_warnings} warning(s)")

        # Exit with non-zero status on errors (for CI)
        if result.errors:
            sys.exit(1)

    except Exception as e:
        if json_output:
            click.echo(json.dumps({"status": "error", "error": str(e)}))
        else:
            click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(lint)


@click.command("api-diff")
@click.argument("old_version")
@click.argument("new_version")
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--package",
    help="Python package name (auto-detected from pyproject.toml if omitted)",
)
@click.option(
    "--json",
    "json_output",
    is_flag=True,
    help="Output results as JSON",
)
@click.option(
    "--graph",
    is_flag=True,
    help="Show dependency graph as Mermaid diagram for the NEW version",
)
@click.option(
    "--timeline",
    is_flag=True,
    help="Show API surface growth timeline across all version tags",
)
@click.option(
    "--symbol",
    help="Track a single symbol across versions (shows signature history)",
)
@click.option(
    "--changes-only",
    is_flag=True,
    help="With --symbol, show only versions where the symbol changed",
)
@click.option(
    "--table",
    is_flag=True,
    help="With --symbol, show parameter evolution as a table",
)
@click.option(
    "--html",
    is_flag=True,
    help="With --symbol --table, output HTML (with disclosure wrapper)",
)
def api_diff_cmd(
    old_version: str,
    new_version: str,
    project_path: str | None,
    package: str | None,
    json_output: bool,
    graph: bool,
    timeline: bool,
    symbol: str | None,
    changes_only: bool,
    table: bool,
    html: bool,
) -> None:
    """Compare the public API between two versions.

    Analyzes how the API surface changed between 'OLD_VERSION' and
    'NEW_VERSION' (git tags). Detects added, removed, and changed symbols,
    tracks parameter changes, and flags breaking changes with migration
    hints.

    \b
    Use 'HEAD' as 'NEW_VERSION' to compare against the working tree.

    \b
    Examples:
      great-docs api-diff v0.1.0 v0.2.0
      great-docs api-diff v1.0.0 HEAD
      great-docs api-diff v0.9.0 v1.0.0 --json
      great-docs api-diff v0.1.0 v0.2.0 --graph
      great-docs api-diff v0.1.0 HEAD --timeline
      great-docs api-diff v0.1.0 v0.5.0 --symbol GreatDocs
      great-docs api-diff v0.1.0 v0.5.0 --symbol GreatDocs --changes-only
      great-docs api-diff v0.1.0 v0.5.0 --symbol GreatDocs --table
      great-docs api-diff v0.1.0 v0.5.0 --symbol GreatDocs --table --html
    """
    import json
    import shutil

    from ._api_diff import (
        api_diff,
        build_dependency_graph,
        build_timeline,
        evolution_table_html,
        evolution_table_text,
        snapshot_at_tag,
        snapshot_from_griffe,
        symbol_history,
        timeline_to_mermaid,
    )

    project_root = Path(project_path) if project_path else Path.cwd()

    try:
        # --- Symbol history mode ---
        if symbol:
            from ._api_diff import list_version_tags

            all_tags = list_version_tags(project_root)
            # Filter to tags between old_version and new_version (inclusive)
            try:
                start_idx = all_tags.index(old_version)
            except ValueError:
                start_idx = 0
            try:
                end_idx = all_tags.index(new_version) + 1
            except ValueError:
                end_idx = len(all_tags)
            selected_tags = all_tags[start_idx:end_idx]

            if not selected_tags:
                click.echo(
                    f"No version tags found between {old_version} and {new_version}.",
                    err=True,
                )
                sys.exit(1)

            hist = symbol_history(project_root, symbol, package_name=package, tags=selected_tags)
            if hist is None:
                click.echo("Could not determine package name.", err=True)
                sys.exit(1)

            if json_output:
                click.echo(json.dumps(hist.to_dict(changes_only=changes_only), indent=2))
            elif table:
                if html:
                    click.echo(
                        evolution_table_html(hist, changes_only=changes_only, disclosure=True)
                    )
                else:
                    click.echo(evolution_table_text(hist, changes_only=changes_only))
            else:
                entries = hist.changed_entries if changes_only else hist.entries
                term_width = shutil.get_terminal_size((80, 24)).columns
                sep = "═" * min(term_width, 60)
                thin = "─" * min(term_width, 60)

                click.echo(f"\n{sep}")
                click.echo(f"Symbol History: {hist.symbol_name}")
                click.echo(f"Package: {hist.package_name}")
                if changes_only:
                    click.echo("Showing only versions with changes")
                click.echo(sep)

                if not entries:
                    click.echo("\n  (no entries)")
                else:
                    for entry in entries:
                        click.echo(f"\n{thin}")
                        if entry.present:
                            tag = ""
                            if entry.change:
                                if entry.change.is_breaking:
                                    tag = "  ⚠ BREAKING"
                                elif entry.change.change_type == "added":
                                    tag = "  ✚ NEW"
                                else:
                                    tag = "  ∆ CHANGED"
                            click.echo(f"  {entry.version}{tag}")
                            click.echo(f"    {entry.signature}")
                            if entry.change and entry.change.details:
                                for detail in entry.change.details:
                                    click.echo(f"      {detail}")
                        else:
                            click.echo(f"  {entry.version}  ✖ NOT PRESENT")
                            if entry.change and entry.change.details:
                                for detail in entry.change.details:
                                    click.echo(f"      {detail}")

                click.echo(f"\n{sep}\n")
            return

        # --- Timeline mode ---
        if timeline:
            tl = build_timeline(project_root, package or "")
            if not tl:
                click.echo("No version tags found or could not build snapshots.", err=True)
                sys.exit(1)
            if json_output:
                click.echo(json.dumps(tl, indent=2))
            else:
                click.echo(timeline_to_mermaid(tl))
            return

        # --- Diff ---
        result = api_diff(project_root, old_version, new_version, package_name=package)

        if result is None:
            click.echo(
                "Could not build API snapshots. Check that the tags exist and the "
                "package source is present at those versions.",
                err=True,
            )
            sys.exit(1)

        # --- Graph mode ---
        if graph:
            from great_docs.core import GreatDocs

            documented = (
                GreatDocs(project_path=str(project_root)).documented_symbol_names(
                    result.package_name
                )
                or None
            )
            # Build graph for the new version
            if new_version.upper() == "HEAD":
                snap = snapshot_from_griffe(
                    result.package_name, version="HEAD", documented_names=documented
                )
            else:
                snap = snapshot_at_tag(
                    project_root, new_version, result.package_name, documented_names=documented
                )
            if snap is None:
                click.echo("Could not build snapshot for graph.", err=True)
                sys.exit(1)
            dep_graph = build_dependency_graph(snap)
            if json_output:
                click.echo(
                    json.dumps(
                        {
                            "nodes": dep_graph.nodes,
                            "inheritance": [
                                {"child": e.child, "parent": e.parent}
                                for e in dep_graph.inheritance
                            ],
                        },
                        indent=2,
                    )
                )
            else:
                click.echo(dep_graph.to_mermaid())
            return

        # --- JSON output ---
        if json_output:
            click.echo(json.dumps(result.to_dict(), indent=2))
            return

        # --- Text output ---
        term_width = shutil.get_terminal_size((80, 24)).columns
        sep = "═" * min(term_width, 60)
        thin = "─" * min(term_width, 60)

        click.echo(f"\n{sep}")
        click.echo(f"API Diff: {result.old_version} → {result.new_version}")
        click.echo(f"Package: {result.package_name}")
        click.echo(sep)

        # Summary
        click.echo(
            f"\n  Added: {len(result.added)}  │  "
            f"Removed: {len(result.removed)}  │  "
            f"Changed: {len(result.changed)}  │  "
            f"Breaking: {len(result.breaking_changes)}"
        )

        if not result.added and not result.removed and not result.changed:
            click.echo("\n✅ No API changes detected.")
            return

        # Added
        if result.added:
            click.echo(f"\n{thin}")
            click.echo(f"  ✚ Added ({len(result.added)})")
            click.echo(thin)
            for c in result.added:
                click.echo(f"    + {c.symbol}")

        # Removed
        if result.removed:
            click.echo(f"\n{thin}")
            click.echo(f"  ✖ Removed ({len(result.removed)})  [BREAKING]")
            click.echo(thin)
            for c in result.removed:
                click.echo(f"    - {c.symbol}")
                if c.migration_hint:
                    click.echo(f"      hint: {c.migration_hint}")

        # Changed
        if result.changed:
            click.echo(f"\n{thin}")
            click.echo(f"  ∆ Changed ({len(result.changed)})")
            click.echo(thin)
            for c in result.changed:
                label = "  ⚠ BREAKING" if c.is_breaking else ""
                click.echo(f"    ~ {c.symbol}{label}")
                for detail in c.details:
                    click.echo(f"        {detail}")
                if c.migration_hint:
                    click.echo(f"        hint: {c.migration_hint}")

        # Breaking summary
        if result.has_breaking_changes:
            click.echo(f"\n{sep}")
            click.echo(f"⚠  {len(result.breaking_changes)} breaking change(s) detected")
            click.echo(sep)

        click.echo()

    except Exception as e:
        if json_output:
            click.echo(json.dumps({"status": "error", "error": str(e)}))
        else:
            click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(api_diff_cmd)


@click.command()
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--check",
    is_flag=True,
    help="Validate version configuration and exit with non-zero on errors",
)
def versions(project_path: str | None, check: bool) -> None:
    """List configured documentation versions.

    Shows the multi-version documentation configuration from 'great-docs.yml',
    including version tags, labels, and status indicators.

    \b
    Examples:
      great-docs versions              # List all configured versions
      great-docs versions --check      # Validate configuration
    """
    from great_docs._versioning import get_latest_version, parse_versions_config
    from great_docs.config import Config

    try:
        project_root = Path(project_path or ".").resolve()
        cfg = Config(project_root)

        if not cfg.has_versions:
            click.echo("No versions configured in great-docs.yml.")
            click.echo("Add a 'versions:' list to enable multi-version docs.")
            return

        try:
            entries = parse_versions_config(cfg.versions)
        except ValueError as e:
            click.echo(f"Error: {e}", err=True)
            sys.exit(1)

        if check:
            click.echo(f"✓ {len(entries)} version(s) configured")
            latest = get_latest_version(entries)
            if latest:
                click.echo(f"  Latest: {latest.tag} ({latest.label})")
            else:
                click.echo("  Warning: no version marked as latest", err=True)
                sys.exit(1)
            return

        # Table header
        click.echo(f"  {'TAG':<12} {'LABEL':<20} {'STATUS':<16} {'API SOURCE'}")
        click.echo(f"  {'─' * 12} {'─' * 20} {'─' * 16} {'─' * 24}")

        for v in entries:
            status = "—"
            if v.latest:
                status = "latest ✓"
            elif v.prerelease:
                status = "prerelease"
            elif v.eol:
                status = "eol"

            api_source = "live introspection"
            if v.api_snapshot:
                api_source = v.api_snapshot
            elif v.git_ref:
                api_source = f"git tag: {v.git_ref}"

            click.echo(f"  {v.tag:<12} {v.label:<20} {status:<16} {api_source}")

        click.echo()

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


cli.add_command(versions)


@click.command("api-snapshot")
@click.argument("version_tag", required=False)
@click.option(
    "--project-path",
    type=click.Path(exists=True, file_okay=False, dir_okay=True),
    help="Path to your project root directory (default: current directory)",
)
@click.option(
    "--package",
    help="Python package name (auto-detected from 'pyproject.toml' if omitted)",
)
@click.option(
    "--output",
    "-o",
    type=click.Path(dir_okay=False),
    help="Output file path (default: '.great-docs/snapshots/<version>.json')",
)
@click.option(
    "--all-tags",
    is_flag=True,
    help="Snapshot all version tags in the repository",
)
@click.option(
    "--force",
    is_flag=True,
    help="Overwrite existing snapshot files",
)
def api_snapshot_cmd(
    version_tag: str | None,
    project_path: str | None,
    package: str | None,
    output: str | None,
    all_tags: bool,
    force: bool,
) -> None:
    """Capture a JSON snapshot of a package's public API.

    Snapshots record every public symbol, its parameters, type annotations,
    and other metadata. They are used to build versioned API reference pages
    and compute diff annotations without needing access to old source code.

    \b
    With no arguments, snapshot the *current* working-tree API:
      'great-docs api-snapshot'

    \b
    Snapshot a specific git tag:
      'great-docs api-snapshot v1.0.0'

    \b
    Snapshot all version tags at once:
      'great-docs api-snapshot --all-tags'

    \b
    Snapshots are saved to '.great-docs/snapshots/<version>.json' by default.
    """
    from ._api_diff import (
        _detect_package_name,
        list_version_tags,
        snapshot_at_tag,
        snapshot_from_griffe,
    )

    project_root = Path(project_path) if project_path else Path.cwd()

    # Resolve package name
    pkg_name = package or _detect_package_name(project_root)
    if not pkg_name:
        click.echo(
            "Could not detect package name. Pass --package or add [project] "
            "name to pyproject.toml.",
            err=True,
        )
        sys.exit(1)

    from great_docs.core import GreatDocs

    documented = GreatDocs(project_path=str(project_root)).documented_symbol_names(pkg_name) or None

    # Default snapshot directory
    snap_dir = project_root / ".great-docs" / "snapshots"

    # Determine which versions to snapshot
    if all_tags:
        tags = list_version_tags(project_root)
        if not tags:
            click.echo("No version tags found in repository.", err=True)
            sys.exit(1)
    elif version_tag:
        tags = [version_tag]
    else:
        # Current working tree
        tags = ["HEAD"]

    saved = 0
    failed = 0

    for tag in tags:
        # Determine output path
        if output and len(tags) == 1:
            out_path = Path(output)
        else:
            label = tag if tag != "HEAD" else "dev"
            out_path = snap_dir / f"{label}.json"

        if out_path.exists() and not force:
            click.echo(f"  ⏭  {tag}: {out_path} already exists (use --force to overwrite)")
            continue

        try:
            if tag == "HEAD":
                snap = snapshot_from_griffe(pkg_name, version="dev", documented_names=documented)
            else:
                snap = snapshot_at_tag(project_root, tag, pkg_name, documented_names=documented)

            if snap is None:
                click.echo(f"  ✗  {tag}: could not build snapshot", err=True)
                failed += 1
                continue

            snap.save(out_path)
            click.echo(f"  ✓  {tag}: {snap.symbol_count} symbols → {out_path}")
            saved += 1

        except Exception as e:
            click.echo(f"  ✗  {tag}: {e}", err=True)
            failed += 1

    click.echo()
    if saved:
        click.echo(f"Saved {saved} snapshot(s).")
    if failed:
        click.echo(f"Failed: {failed}")
        sys.exit(1)


cli.add_command(api_snapshot_cmd)


# ══════════════════════════════════════════════════════════════════════════════
# SKILL MANAGEMENT COMMANDS
# ══════════════════════════════════════════════════════════════════════════════


@click.group(cls=OrderedGroup)
def skill():
    """Manage AI coding agent skills.

    Install, check, and list SKILL.md files so AI coding agents can
    learn to use your Python package.

    \b
    Quick start:
      great-docs skill install great-docs      # install Great Docs' own skill
      great-docs skill install great-tables    # install from PyPI package
      great-docs skill install pointblank      # works with any GD-powered package
      great-docs skill check                   # verify freshness
      great-docs skill check --update          # auto-update outdated skills
      great-docs skill list great-tables       # see what's bundled
    """


@click.command(name="install")
@click.argument("source", required=False)
@click.option(
    "-g",
    "--global",
    "global_",
    is_flag=True,
    help="Install the skill globally (~/) instead of in the current repository.",
)
@click.option(
    "-p",
    "--path",
    type=str,
    help="Custom path where to install the skill.",
)
@click.option(
    "-d",
    "--detect",
    is_flag=True,
    help="Automatically detect and update existing installations.",
)
@click.option(
    "--agent",
    type=click.Choice(["claude", "copilot", "cursor", "windsurf", "opencode", "codex"]),
    help="Target agent format (auto-detected if not specified).",
)
@click.option(
    "--name",
    "skill_name",
    type=str,
    help="Override the skill name.",
)
def skill_install(
    source: str | None,
    global_: bool,
    path: str | None,
    detect: bool,
    agent: str | None,
    skill_name: str | None,
) -> None:
    """Install skills for AI coding agents.

    SOURCE can be a Python package name or a documentation site URL.
    If omitted, looks for skills in the current package.

    \b
    Examples:
      great-docs skill install                    # current package (pyproject.toml)
      great-docs skill install great-tables       # any installed package
      great-docs skill install polars pointblank  # multiple packages at once
      great-docs skill install https://posit-dev.github.io/great-tables/
      great-docs skill install great-tables --agent claude           # Claude Code
      great-docs skill install great-tables --agent copilot          # GitHub Copilot
      great-docs skill install great-tables --global                 # install to ~/
      great-docs skill install great-tables --path .claude/skills/gt # custom path
      great-docs skill install --detect           # find and refresh all skills
    """
    from ._skill_install import install_skill as _install_skill

    kwargs: dict = {
        "global_": global_,
        "path": path,
        "detect": detect,
        "skill_name": skill_name,
    }

    if agent:
        kwargs["agent"] = agent

    if source:
        if source.startswith(("http://", "https://")):
            kwargs["url"] = source
        else:
            kwargs["package"] = source
    else:
        # Try to detect current package from pyproject.toml
        project_root = Path.cwd()
        pkg_name = _detect_current_package(project_root)
        if pkg_name:
            kwargs["package"] = pkg_name
        else:
            click.echo(
                "Error: No source specified and no package found in current directory.", err=True
            )
            click.echo("Usage: great-docs skill install <package-or-url>", err=True)
            sys.exit(1)

    results = _install_skill(**kwargs)
    if not results:
        sys.exit(1)


@click.command(name="check")
@click.argument("package", required=False)
@click.option(
    "-g",
    "--global",
    "global_",
    is_flag=True,
    help="Only check global installations (in home directory).",
)
@click.option(
    "-l",
    "--local",
    is_flag=True,
    default=True,
    help="Only check local installations (in current repository).",
)
@click.option(
    "-u",
    "--update",
    is_flag=True,
    help="Automatically update any outdated skills found.",
)
def skill_check(
    package: str | None,
    global_: bool,
    local: bool,
    update: bool,
) -> None:
    """Check if installed skills are up to date.

    Scans for installed SKILL.md files and compares their content hash
    with the currently installed package to detect changes.

    \b
    Examples:
      great-docs skill check                # check all installed skills
      great-docs skill check great-tables   # check a specific package
      great-docs skill check --global       # check global installations only
      great-docs skill check --update       # reinstall any that have changed
    """
    from ._skill_install import check_skill as _check_skill

    click.echo("Checking installed skills...")
    results = _check_skill(
        package=package,
        global_=global_,
        local=local,
        update=update,
    )

    if not results:
        click.echo("No installed skills found.")
    else:
        current = sum(1 for r in results if r["status"] == "current")
        outdated = sum(1 for r in results if r["status"] == "outdated")
        updated = sum(1 for r in results if r["status"] == "updated")
        local = sum(1 for r in results if r["status"] == "local")

        click.echo()
        parts = []
        if current:
            parts.append(f"{current} current")
        if outdated:
            parts.append(f"{outdated} outdated")
        if updated:
            parts.append(f"{updated} updated")
        if local:
            parts.append(f"{local} local")
        click.echo(f"Summary: {', '.join(parts)}")


@click.command(name="list")
@click.argument("source", required=False)
@click.option(
    "--url",
    type=str,
    help="Documentation site URL to query for available skills.",
)
def skill_list(source: str | None, url: str | None) -> None:
    """List available skills from a package or URL.

    Shows all SKILL.md files bundled in a package or discoverable at a URL,
    including their names and file paths.

    \b
    Examples:
      great-docs skill list                   # current package (pyproject.toml)
      great-docs skill list great-tables      # any installed package
      great-docs skill list https://posit-dev.github.io/great-tables/
    """
    from ._skill_install import list_skills as _list_skills

    if source:
        if source.startswith(("http://", "https://")):
            url = source
            source = None

    if url:
        click.echo(f"Skills available at {url}:")
        results = _list_skills(url=url)
    elif source:
        click.echo(f"Skills bundled in '{source}':")
        results = _list_skills(package=source)
    else:
        project_root = Path.cwd()
        pkg_name = _detect_current_package(project_root)
        if pkg_name:
            click.echo(f"Skills bundled in '{pkg_name}':")
            results = _list_skills(package=pkg_name)
        else:
            click.echo("Error: No source specified and no package found.", err=True)
            sys.exit(1)

    if not results:
        click.echo("No skills found.")
        sys.exit(1)


def _detect_current_package(project_root: Path) -> str | None:
    """Detect the current package name from pyproject.toml."""
    pyproject_path = project_root / "pyproject.toml"
    if not pyproject_path.exists():
        return None

    try:
        try:
            import tomllib
        except ImportError:
            import tomli as tomllib  # type: ignore[no-redef]

        with open(pyproject_path, "rb") as f:
            data = tomllib.load(f)

        return data.get("project", {}).get("name")
    except Exception:
        return None


skill.add_command(skill_install)
skill.add_command(skill_check)
skill.add_command(skill_list)
cli.add_command(skill)


# ---------------------------------------------------------------------------
# termshow commands
# ---------------------------------------------------------------------------


@click.group()
def termshow():
    """Terminal recording and playback for CLI/TUI documentation.

    Record terminal sessions, edit them with YAML scripts, and render
    them as SVG frame sequences for the termshow player.
    """
    pass


@click.command("record")
@click.argument("output", type=click.Path())
@click.option("--cols", type=int, default=None, help="Terminal width in columns")
@click.option("--rows", type=int, default=None, help="Terminal height in rows")
@click.option("--shell", type=str, default=None, help="Shell to spawn (default: $SHELL)")
@click.option("--capture-input", is_flag=True, help="Also capture keyboard input events")
def term_record(
    output: str, cols: int | None, rows: int | None, shell: str | None, capture_input: bool
) -> None:
    """Record a terminal session to a .termshow file.

    Spawns an interactive shell and captures all output with timing.
    Press Ctrl+D or type 'exit' to stop recording.
    """
    from ._term_player.recorder import record_session

    if not output.endswith(".termshow"):
        output += ".termshow"

    record_session(output, shell=shell, cols=cols, rows=rows, capture_input=capture_input)


@click.command("render")
@click.argument("source", type=click.Path(exists=True))
@click.option(
    "--output-dir", "-o", type=click.Path(), default=None, help="Output directory for frames"
)
@click.option("--interval", type=float, default=2.0, help="Keyframe interval in seconds")
@click.option(
    "--script", type=click.Path(exists=True), default=None, help="Path to .termshow.yml script"
)
def term_render(source: str, output_dir: str | None, interval: float, script: str | None) -> None:
    """Render a .termshow recording into SVG frames.

    Processes the recording through the virtual terminal emulator and
    produces SVG keyframes + a manifest.json for the player.
    """
    from ._term_player import apply_script, generate_manifest, parse_asciicast, parse_termshow
    from ._term_player.script import load_script

    source_path = Path(source)

    # Parse recording
    if source_path.suffix == ".cast":
        recording = parse_asciicast(source_path)
    else:
        recording = parse_termshow(source_path)

    # Load and apply script
    script_obj = None
    if script:
        script_obj = load_script(script)
    else:
        # Auto-detect script file
        script_path = source_path.with_suffix(".termshow.yml")
        if script_path.exists():
            script_obj = load_script(script_path)

    if script_obj:
        recording = apply_script(recording, script_obj)

    # Determine output directory
    if output_dir is None:
        output_dir = str(source_path.parent / "termshow" / source_path.stem)

    # Generate manifest and frames
    manifest = generate_manifest(
        recording, script_obj, output_dir=output_dir, keyframe_interval=interval
    )

    n_frames = len(manifest.keyframes)
    click.echo(f"✓ Rendered {n_frames} keyframes to {output_dir}/")
    click.echo(f"  Duration: {manifest.duration:.1f}s | Chapters: {len(manifest.chapters)}")


@click.command("import-cast")
@click.argument("source", type=click.Path(exists=True))
@click.argument("output", type=click.Path())
def term_import_cast(source: str, output: str) -> None:
    """Import an asciicast (.cast) file to .termshow format."""
    from ._term_player.importer import import_asciicast

    if not output.endswith(".termshow"):
        output += ".termshow"

    recording = import_asciicast(source, output)
    click.echo(f"✓ Imported {source} → {output}")
    click.echo(f"  Duration: {recording.duration:.1f}s | Events: {len(recording.events)}")


termshow.add_command(term_record)
termshow.add_command(term_render)
termshow.add_command(term_import_cast)


@click.command("edit")
@click.argument("source", type=click.Path(exists=True))
@click.option("--port", type=int, default=8765, help="Local server port")
@click.option("--no-browser", is_flag=True, help="Don't auto-open browser")
def term_edit(source: str, port: int, no_browser: bool) -> None:
    """Open the Termshow Editor for a .termshow recording.

    Launches a browser-based timeline editor for adding chapters,
    annotations, and cuts. Changes are saved back to the .termshow.yml
    script file.

    Examples:
      great-docs termshow edit demos/my-demo.termshow
      great-docs termshow edit demos/install.termshow --port 9000
    """
    from ._term_player.editor import serve_editor

    serve_editor(source, port=port, no_browser=no_browser)


termshow.add_command(term_edit)
cli.add_command(termshow)


def main() -> None:
    """Main CLI entry point for great-docs."""
    cli()


if __name__ == "__main__":
    main()
