"""Tests for App.generate_docs() method."""

import tempfile
from pathlib import Path
from textwrap import dedent
from typing import Annotated

import pytest

from cyclopts import App, Parameter


def test_generate_docs_simple_app():
    """Test basic documentation generation for a simple app."""
    app = App(name="myapp", help="A simple CLI application")

    @app.default
    def main(name: str, verbose: bool = False):
        """Main command.

        Parameters
        ----------
        name : str
            Your name.
        verbose : bool
            Enable verbose output.
        """
        pass

    actual = app.generate_docs()

    expected = dedent(
        """\
        # myapp

        ```console
        myapp NAME [ARGS]
        ```

        A simple CLI application

        **Parameters**:

        * `NAME, --name`: Your name.  **[required]**
        * `VERBOSE, --verbose, --no-verbose`: Enable verbose output.  *[default: False]*
        """
    )

    assert actual == expected


def test_generate_docs_with_commands():
    """Test documentation generation with subcommands."""
    app = App(name="myapp", help="CLI with commands")

    @app.command
    def serve(port: int = 8000):
        """Start the server.

        Parameters
        ----------
        port : int
            Port number.
        """
        pass

    @app.command
    def build(output: str = "./dist"):
        """Build the project.

        Parameters
        ----------
        output : str
            Output directory.
        """
        pass

    actual = app.generate_docs()

    # Check structure without being too specific about Usage lines that vary by context
    assert "# myapp" in actual
    assert "CLI with commands" in actual

    # Main usage section
    assert "myapp COMMAND" in actual

    # Commands list with hyperlinks
    assert "**Commands**:" in actual
    assert "* [`build`](#myapp-build): Build the project." in actual
    assert "* [`serve`](#myapp-serve): Start the server." in actual

    # Serve command details (now shows full path)
    assert "## myapp serve" in actual
    assert "Start the server." in actual
    assert "**Parameters**:" in actual
    assert "* `PORT, --port`: Port number.  *[default: 8000]*" in actual

    # Build command details (now shows full path)
    assert "## myapp build" in actual
    assert "Build the project." in actual
    assert "* `OUTPUT, --output`: Output directory.  *[default: ./dist]*" in actual


def test_generate_docs_recursive():
    """Test recursive documentation generation."""
    app = App(name="myapp", help="Main app")

    subapp = App(name="db", help="Database commands")

    @subapp.command
    def migrate():
        """Run database migrations."""
        pass

    @subapp.command
    def backup(output: str):
        """Backup the database.

        Parameters
        ----------
        output : str
            Backup file path.
        """
        pass

    app.command(subapp)

    actual = app.generate_docs(recursive=True)

    # Verify structure of recursive documentation
    assert "# myapp" in actual
    assert "Main app" in actual
    assert "## myapp db" in actual
    assert "Database commands" in actual
    assert "migrate" in actual
    assert "backup" in actual
    assert "Run database migrations" in actual
    assert "Backup the database" in actual


def test_generate_docs_non_recursive():
    """Test non-recursive documentation generation."""
    app = App(name="myapp", help="Main app")

    subapp = App(name="db", help="Database commands")

    @subapp.command
    def migrate():
        """Run database migrations."""
        pass

    app.command(subapp)

    actual = app.generate_docs(recursive=False)

    # Note: In non-recursive mode, subcommands like 'migrate' don't get headings,
    # so they appear in the ToC but the link is broken. This is a known limitation.
    # The current implementation always collects ToC entries recursively.
    # Note: The 'db' subcommand doesn't show usage because it has no default_command.
    expected = dedent(
        """\
        # myapp

        ```console
        myapp COMMAND
        ```

        Main app

        ## Table of Contents

        - [`db`](#myapp-db)
            - [`migrate`](#myapp-db-migrate)

        **Commands**:

        * [`db`](#myapp-db): Database commands

        ## myapp db

        Database commands
        """
    )

    assert actual == expected


def test_generate_docs_with_hidden_commands(mocker):
    """Test documentation with hidden commands."""
    # Mock sys.argv[0] for consistent output
    mocker.patch("sys.argv", ["myapp"])

    app = App(name="myapp", help="App with hidden commands")

    @app.command
    def visible():
        """Visible command."""
        pass

    @app.command(show=False)
    def hidden():
        """Hidden command."""
        pass

    # Test WITHOUT include_hidden
    actual_without_hidden = app.generate_docs(include_hidden=False)

    expected_without_hidden = dedent(
        """\
        # myapp

        ```console
        myapp COMMAND
        ```

        App with hidden commands

        ## Table of Contents

        - [`visible`](#myapp-visible)

        **Commands**:

        * [`visible`](#myapp-visible): Visible command.

        ## myapp visible

        ```console
        myapp visible
        ```

        Visible command.
        """
    )

    assert actual_without_hidden == expected_without_hidden

    # Test WITH include_hidden
    actual_with_hidden = app.generate_docs(include_hidden=True)

    # Verify the hidden command is present when include_hidden=True
    assert "## myapp hidden" in actual_with_hidden
    assert "Hidden command." in actual_with_hidden
    # Note: --help and --version are builtin flags, not commands, so they
    # should not appear in the Commands section even with include_hidden=True


def test_generate_docs_with_required_parameters():
    """Test documentation with required parameters."""
    app = App(name="myapp")

    @app.default
    def main(
        required: Annotated[str, Parameter(help="Required parameter")],
        optional: str = "default",
    ):
        """Main command."""
        pass

    actual = app.generate_docs()

    expected = dedent(
        """\
        # myapp

        ```console
        myapp REQUIRED [ARGS]
        ```

        Main command.

        **Parameters**:

        * `REQUIRED, --required`: Required parameter  **[required]**
        * `OPTIONAL, --optional`:   *[default: default]*
        """
    )

    assert actual == expected


def test_generate_docs_with_choices():
    """Test documentation with parameter choices."""
    from enum import Enum

    app = App(name="myapp")

    class Color(Enum):
        RED = "red"
        GREEN = "green"
        BLUE = "blue"

    @app.default
    def main(color: Color = Color.RED):
        """Choose a color."""
        pass

    actual = app.generate_docs()

    # Should show available choices in the documentation
    assert "red" in actual
    assert "green" in actual
    assert "blue" in actual
    assert "choices:" in actual


def test_generate_docs_with_custom_usage():
    """Test documentation with custom usage string."""
    app = App(name="myapp", usage="myapp [OPTIONS] <input> <output>")

    @app.default
    def main():
        """Main command."""
        pass

    actual = app.generate_docs()

    expected = dedent(
        """\
        # myapp

        ```console
        myapp [OPTIONS] <input> <output>
        ```

        Main command.
        """
    )

    assert actual == expected


def test_generate_docs_no_usage():
    """Test documentation with suppressed usage."""
    app = App(name="myapp", usage="")  # Empty string suppresses usage

    @app.default
    def main():
        """Main command."""
        pass

    actual = app.generate_docs()

    expected = dedent(
        """\
        # myapp

        Main command.
        """
    )

    assert actual == expected


def test_generate_docs_write_to_file():
    """Test writing documentation to a file."""
    app = App(name="myapp", help="Test app")

    @app.default
    def main():
        """Main command."""
        pass

    with tempfile.TemporaryDirectory() as tmpdir:
        output_path = Path(tmpdir) / "docs" / "cli.md"

        # Generate docs
        actual = app.generate_docs()

        expected = dedent(
            """\
            # myapp

            ```console
            myapp
            ```

            Test app
            """
        )

        # Write to file manually
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_text(actual)

        # Check file was created and contains expected content
        assert output_path.exists()
        content = output_path.read_text()
        assert content == expected

        # Should return the expected content
        assert actual == expected


def test_generate_docs_output_format_explicit():
    """Test explicitly specifying output format."""
    app = App(name="myapp", help="Test app")

    @app.default
    def main():
        """Main command."""
        pass

    # Explicitly specify markdown format
    actual = app.generate_docs(output_format="markdown")

    expected = dedent(
        """\
        # myapp

        ```console
        myapp
        ```

        Test app
        """
    )

    assert actual == expected


def test_generate_docs_output_format_markdown():
    """Test generating markdown format documentation."""
    app = App(name="myapp", help="Test app")

    @app.default
    def main():
        """Main command."""
        pass

    # Test markdown format (default)
    docs_md = app.generate_docs()
    assert "# myapp" in docs_md

    # Test explicitly specifying markdown
    docs_explicit = app.generate_docs(output_format="markdown")
    assert "# myapp" in docs_explicit
    assert docs_md == docs_explicit


def test_generate_docs_invalid_format():
    """Test that invalid output format raises ValueError."""
    app = App(name="myapp", help="Test app")

    with pytest.raises(ValueError, match='Unsupported format "pdf"'):
        app.generate_docs(output_format="pdf")  # type: ignore[arg-type]

    with pytest.raises(ValueError, match='Unsupported format "invalid"'):
        app.generate_docs(output_format="invalid")  # type: ignore[arg-type]


def test_generate_docs_with_heading_levels():
    """Test custom heading levels."""
    app = App(name="myapp", help="Main app")

    @app.command
    def cmd():
        """A command."""
        pass

    actual = app.generate_docs(heading_level=2)

    # Check the heading levels
    assert "## myapp" in actual
    assert "Main app" in actual
    assert "**Commands**:" in actual
    assert "### myapp cmd" in actual


def test_generate_docs_complex_nested_app():
    """Test complex nested application documentation."""
    app = App(name="cli", help="Complex CLI tool")

    # Add top-level command
    @app.command
    def version():
        """Show version information."""
        pass

    # Create nested subapp
    git = App(name="git", help="Git operations")

    @git.command
    def clone(
        url: Annotated[str, Parameter(help="Repository URL")],
        depth: int = 1,
    ):
        """Clone a repository.

        Parameters
        ----------
        url : str
            The repository URL to clone.
        depth : int
            Clone depth.
        """
        pass

    @git.command
    def push(force: bool = False):
        """Push changes.

        Parameters
        ----------
        force : bool
            Force push.
        """
        pass

    app.command(git)

    docs = app.generate_docs(recursive=True)

    # Check structure
    assert "# cli" in docs
    assert "Complex CLI tool" in docs
    assert "version" in docs
    assert "Show version information" in docs
    assert "## cli git" in docs
    assert "Git operations" in docs
    assert "clone" in docs
    assert "push" in docs
    assert "Clone a repository" in docs
    assert "Push changes" in docs
    assert "Repository URL" in docs
    assert "Force push" in docs


def test_generate_docs_with_aliases():
    """Test documentation with command aliases."""
    app = App(name="myapp")

    @app.command(alias=["s", "srv"])
    def serve():
        """Start the server."""
        pass

    docs = app.generate_docs()

    assert "serve" in docs
    assert "Start the server" in docs
    # Note: Aliases might not be shown in current implementation
    # This is acceptable as the main command name is shown


def test_generate_docs_with_meta_app():
    """Test documentation generation with meta app."""
    from typing import Annotated

    app = App(name="myapp", help="Main application")

    @app.default
    def main(input_file: str):
        """Process a file.

        Parameters
        ----------
        input_file : str
            Input file path.
        """
        pass

    @app.meta.default
    def meta(
        verbose: bool = False,
        config: Annotated[str | None, Parameter(help="Config file")] = None,
    ):
        """Meta app for global options.

        Parameters
        ----------
        verbose : bool
            Enable verbose output.
        config : str
            Configuration file path.
        """
        pass

    actual = app.generate_docs()

    expected = dedent(
        """\
        # myapp

        ```console
        myapp INPUT-FILE [ARGS]
        ```

        Main application

        **Parameters**:

        * `INPUT-FILE, --input-file`: Input file path.  **[required]**
        * `VERBOSE, --verbose, --no-verbose`: Enable verbose output.  *[default: False]*
        * `CONFIG, --config`: Config file
        """
    )

    assert actual == expected


def test_generate_docs_meta_app_with_commands():
    """Test documentation with meta app and subcommands."""
    app = App(name="myapp", help="Main app with meta")

    @app.meta.default
    def meta(
        debug: bool = False,
    ):
        """Global meta options.

        Parameters
        ----------
        debug : bool
            Enable debug mode.
        """
        pass

    @app.command
    def serve(port: int = 8000):
        """Start the server.

        Parameters
        ----------
        port : int
            Port number.
        """
        pass

    @app.command
    def build():
        """Build the project."""
        pass

    actual = app.generate_docs()

    # Meta parameters should appear with the main app
    assert "debug" in actual.lower() or "DEBUG" in actual
    assert "Enable debug mode" in actual

    # Commands should still appear
    assert "serve" in actual
    assert "build" in actual
    assert "Start the server" in actual
    assert "Build the project" in actual


def test_generate_docs_nested_meta_apps():
    """Test documentation with nested apps that have their own meta apps."""
    # Create a subapp with its own meta
    db_app = App(name="db", help="Database commands")

    @db_app.meta.default
    def db_meta(
        connection: str = "sqlite:///:memory:",
    ):
        """Database meta options.

        Parameters
        ----------
        connection : str
            Database connection string.
        """
        pass

    @db_app.command
    def migrate():
        """Run database migrations."""
        pass

    # Main app with its own meta
    app = App(name="myapp", help="Main application")

    @app.meta.default
    def main_meta(
        verbose: bool = False,
    ):
        """Global options.

        Parameters
        ----------
        verbose : bool
            Verbose output.
        """
        pass

    # Register the db app with its meta
    app.command(db_app.meta, name="db")

    docs = app.generate_docs(recursive=True)

    # Check main app meta parameters appear
    assert "verbose" in docs.lower() or "VERBOSE" in docs
    assert "Verbose output" in docs

    # Check db subcommand appears
    assert "## myapp db" in docs
    # When registering db_app.meta, it uses the meta's docstring
    assert "Database meta options" in docs

    # Check nested db commands appear when recursive
    assert "migrate" in docs
    assert "Run database migrations" in docs

    # Check db meta parameters appear with db command
    assert "connection" in docs.lower() or "CONNECTION" in docs
    assert "Database connection string" in docs


def test_generate_docs_flatten_commands():
    """Test flatten_commands option for markdown documentation."""
    app = App(name="myapp", help="Main app")

    sub1 = App(name="sub1", help="First subcommand")

    @sub1.command
    def nested1():
        """Nested command 1."""
        pass

    @sub1.command
    def nested2():
        """Nested command 2."""
        pass

    sub2 = App(name="sub2", help="Second subcommand")

    @sub2.command
    def nested3():
        """Nested command 3."""
        pass

    app.command(sub1)
    app.command(sub2)

    # Without flatten_commands - hierarchical headings (but full paths to avoid collisions)
    docs_hierarchical = app.generate_docs(flatten_commands=False)

    # Main app should be h1
    assert "# myapp" in docs_hierarchical
    # Subcommands should be h2 with full paths
    assert "## myapp sub1" in docs_hierarchical
    assert "## myapp sub2" in docs_hierarchical
    # Nested commands should be h3 (properly nested under their parent) with full paths
    assert "### myapp sub1 nested1" in docs_hierarchical
    assert "### myapp sub1 nested2" in docs_hierarchical
    assert "### myapp sub2 nested3" in docs_hierarchical

    # With flatten_commands - all at same level
    docs_flat = app.generate_docs(flatten_commands=True)

    # Main app should be h1
    assert "# myapp" in docs_flat
    # All subcommands should also be h1 (flattened) with full paths
    assert "# myapp sub1" in docs_flat
    assert "# myapp sub2" in docs_flat
    # All nested commands should also be h1 (flattened) with full paths
    assert "# myapp sub1 nested1" in docs_flat
    assert "# myapp sub1 nested2" in docs_flat
    assert "# myapp sub2 nested3" in docs_flat
    # Should NOT have h2 command headings when flattened
    assert "## myapp sub" not in docs_flat


def test_generate_docs_toc_anchors_flattened():
    """Test that TOC anchors match actual heading anchors in flattened mode."""
    app = App(name="myapp", help="My app")

    sub1 = App(name="sub1", help="First subcommand")

    @sub1.command
    def nested1():
        """Nested command 1."""
        pass

    @sub1.command
    def nested2():
        """Nested command 2."""
        pass

    sub2 = App(name="sub2", help="Second subcommand")

    @sub2.command
    def nested3():
        """Nested command 3."""
        pass

    app.command(sub1)
    app.command(sub2)

    docs = app.generate_docs(flatten_commands=True)

    # In flattened mode, TOC anchors should include full path with app name
    assert "- [`sub1`](#myapp-sub1)" in docs
    assert "- [`nested1`](#myapp-sub1-nested1)" in docs
    assert "- [`nested2`](#myapp-sub1-nested2)" in docs
    assert "- [`sub2`](#myapp-sub2)" in docs
    assert "- [`nested3`](#myapp-sub2-nested3)" in docs

    # Verify headings exist that will generate these anchors (all show full paths)
    assert "# myapp sub1" in docs
    assert "# myapp sub1 nested1" in docs
    assert "# myapp sub1 nested2" in docs
    assert "# myapp sub2" in docs
    assert "# myapp sub2 nested3" in docs


def test_generate_docs_toc_anchors_hierarchical():
    """Test that TOC anchors match actual heading anchors in hierarchical mode."""
    app = App(name="myapp", help="My app")

    sub1 = App(name="sub1", help="First subcommand")

    @sub1.command
    def nested1():
        """Nested command 1."""
        pass

    @sub1.command
    def nested2():
        """Nested command 2."""
        pass

    sub2 = App(name="sub2", help="Second subcommand")

    @sub2.command
    def nested3():
        """Nested command 3."""
        pass

    app.command(sub1)
    app.command(sub2)

    docs = app.generate_docs(flatten_commands=False)

    # TOC anchors use full paths with app name to avoid collisions (e.g., files.cp vs other.cp)
    assert "- [`sub1`](#myapp-sub1)" in docs
    assert "- [`nested1`](#myapp-sub1-nested1)" in docs
    assert "- [`nested2`](#myapp-sub1-nested2)" in docs
    assert "- [`sub2`](#myapp-sub2)" in docs
    assert "- [`nested3`](#myapp-sub2-nested3)" in docs

    # Verify headings exist that will generate these anchors (now showing full paths)
    assert "## myapp sub1" in docs
    assert "### myapp sub1 nested1" in docs
    assert "### myapp sub1 nested2" in docs
    assert "## myapp sub2" in docs
    assert "### myapp sub2 nested3" in docs


def test_generate_docs_toc_anchor_collisions():
    """Test that headings with same command name don't collide (full paths prevent this)."""
    app = App(name="myapp", help="My app")

    sub1 = App(name="sub1", help="First subcommand")

    @sub1.command(name="create")
    def create1():
        """Create via sub1."""
        pass

    @sub1.command(name="delete")
    def delete1():
        """Delete via sub1."""
        pass

    sub2 = App(name="sub2", help="Second subcommand")

    @sub2.command(name="create")
    def create2():
        """Create via sub2."""
        pass

    @sub2.command(name="delete")
    def delete2():
        """Delete via sub2."""
        pass

    app.command(sub1)
    app.command(sub2)

    docs = app.generate_docs(flatten_commands=False)

    # Headings now use full paths with app name, so no collisions occur
    assert "- [`sub1`](#myapp-sub1)" in docs
    assert "    - [`create`](#myapp-sub1-create)" in docs
    assert "    - [`delete`](#myapp-sub1-delete)" in docs

    assert "- [`sub2`](#myapp-sub2)" in docs
    assert "    - [`create`](#myapp-sub2-create)" in docs
    assert "    - [`delete`](#myapp-sub2-delete)" in docs

    # Verify the actual headings use full paths
    assert "### myapp sub1 create" in docs
    assert "### myapp sub1 delete" in docs
    assert "### myapp sub2 create" in docs
    assert "### myapp sub2 delete" in docs


def test_generate_docs_nested_command_list_hyperlinks():
    """Test that command list hyperlinks in nested apps use full command path.

    This tests that when a deeply nested command (e.g., app -> parent -> child -> grandchild)
    lists its subcommands in the **Commands**: section, the hyperlinks use the full path
    (e.g., #app-parent-child-grandchild) not just the app name and command
    (e.g., #child-grandchild).
    """
    app = App(name="darts", help="Main app")

    training = App(name="training", help="Training commands")

    create_dataset = App(name="create-dataset", help="Dataset creation")

    @create_dataset.command
    def planet():
        """Preprocess Planet data for training."""
        pass

    @create_dataset.command
    def sentinel2():
        """Preprocess Sentinel-2 data for training."""
        pass

    training.command(create_dataset)
    app.command(training)

    docs = app.generate_docs()

    # The command list in create-dataset should have hyperlinks with the FULL path
    # NOT just #create-dataset-planet (wrong) but #darts-training-create-dataset-planet (correct)
    assert "* [`planet`](#darts-training-create-dataset-planet):" in docs
    assert "* [`sentinel2`](#darts-training-create-dataset-sentinel2):" in docs

    # Verify the headings exist with matching anchors
    assert "### darts training create-dataset" in docs
    assert "#### darts training create-dataset planet" in docs
    assert "#### darts training create-dataset sentinel2" in docs

    # Also verify the root-level command list (darts listing training)
    assert "* [`training`](#darts-training):" in docs


def test_generate_docs_usage_name_overrides_root_usage():
    """usage_name replaces the app name in the root Usage: line."""
    app = App(name="cli", help="A CLI")

    @app.default
    def main(name: str = "world"):
        """Greet.

        Parameters
        ----------
        name : str
            Name.
        """
        pass

    actual = app.generate_docs(usage_name="uv run cli")
    assert "# cli" in actual  # heading unchanged
    usage_block = actual.split("```console")[1].split("```")[0].strip()
    first_line = next(line for line in usage_block.splitlines() if line.strip())
    assert first_line.startswith("uv run cli"), f"usage line: {first_line!r}"


def test_generate_docs_usage_name_overrides_subcommand_usage():
    """usage_name prefixes every subcommand's Usage: line too."""
    app = App(name="cli", help="A CLI")

    @app.command
    def serve(port: int = 8000):
        """Start the server.

        Parameters
        ----------
        port : int
            Port.
        """
        pass

    actual = app.generate_docs(usage_name="uv run cli")
    # Subcommand heading still uses plain app name
    assert "## cli serve" in actual
    # TOC anchor unchanged
    assert "](#cli-serve)" in actual
    # Every console block begins with "uv run cli"
    for block in actual.split("```console")[1:]:
        body = block.split("```")[0].strip()
        first_line = next(line for line in body.splitlines() if line.strip())
        assert first_line.startswith("uv run cli"), f"usage line should start with 'uv run cli': {first_line!r}"


def test_generate_docs_usage_name_none_is_default_behavior():
    """Passing usage_name=None (default) produces identical output to omitting it."""
    app = App(name="cli", help="A CLI")

    @app.default
    def main():
        """Entry."""
        pass

    assert app.generate_docs() == app.generate_docs(usage_name=None)


@pytest.mark.parametrize("fmt", ["markdown", "rst", "html"])
def test_generate_docs_usage_name_applies_across_formats(fmt):
    """usage_name is accepted by every output format and affects its Usage: output."""
    app = App(name="cli", help="A CLI")

    @app.default
    def main():
        """Entry."""
        pass

    output = app.generate_docs(output_format=fmt, usage_name="uv run cli")
    assert "uv run cli" in output


def test_generate_docs_usage_name_empty_string_is_inserted_verbatim():
    """Empty usage_name is inserted verbatim; the bare "cli" prefix is dropped."""
    app = App(name="cli", help="A CLI")

    @app.command
    def serve(port: int = 8000):
        """Start the server.

        Parameters
        ----------
        port : int
            Port.
        """
        pass

    actual = app.generate_docs(usage_name="")
    # Heading untouched
    assert "## cli serve" in actual
    # Subcommand usage line no longer starts with "cli " — the root token has been replaced by "".
    subcommand_usage = actual.split("## cli serve")[1].split("```console")[1].split("```")[0].strip()
    first_line = next(line for line in subcommand_usage.splitlines() if line.strip())
    assert not first_line.startswith("cli ")
    assert first_line.startswith("serve")
