"""Build hand-written documentation examples from tested fixtures."""

from __future__ import annotations

import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable

ROOT = Path(__file__).parent.parent
DOCS = ROOT / "docs"
TEST_DATA = ROOT / "tests" / "data"
EXPECTED_MAIN = TEST_DATA / "expected" / "main"
EXPECTED_INPUT_MODEL = EXPECTED_MAIN / "input_model"
EXPECTED_JSON_SCHEMA = EXPECTED_MAIN / "jsonschema"
EXPECTED_OPENAPI = EXPECTED_MAIN / "openapi"
INPUT_MODEL_DATA = TEST_DATA / "python" / "input_model"
JSON_SCHEMA_DATA = TEST_DATA / "jsonschema"
OPENAPI_DATA = TEST_DATA / "openapi"

BEGIN_MARKER_TEMPLATE = "<!-- BEGIN AUTO-GENERATED DOC EXAMPLE: {example_id} -->"
END_MARKER_TEMPLATE = "<!-- END AUTO-GENERATED DOC EXAMPLE: {example_id} -->"

PYTHON_GENERATED_HEADER_PATTERN = re.compile(
    r"\A# generated by datamodel-codegen:\n"
    r"#   filename:  .+\n"
    r"(?:#   timestamp:\s+.+(?:\n|$))?"
    r"\n?",
)


@dataclass(frozen=True, slots=True)
class DocsExample:
    """One generated docs example section."""

    example_id: str
    path: Path
    render: Callable[[], str]


@dataclass(frozen=True, slots=True)
class MarkdownCodeBlock:
    """One labeled code block in a generated Markdown example."""

    label: str
    language: str
    path: Path | None = None
    content: str | None = None
    strip_python_header: bool = False


def read_text(path: Path) -> str:
    """Read a UTF-8 fixture file."""
    return path.read_text(encoding="utf-8").rstrip()


def read_python_output(path: Path) -> str:
    """Read generated Python output without volatile generated-file headers."""
    return PYTHON_GENERATED_HEADER_PATTERN.sub("", read_text(path), count=1)


def fenced(language: str, content: str) -> str:
    """Render a Markdown fenced code block."""
    return f"\n```{language}\n{content.rstrip()}\n```\n"


def details(summary: str, language: str, content: str) -> str:
    """Render a collapsible Markdown details block containing a code fence."""
    return f"\n<details>\n<summary>{summary}</summary>\n{fenced(language, content)}</details>\n"


def render_file_example(*, language: str, path: Path, strip_python_header: bool = False) -> str:
    """Render one fixture file as a Markdown code block."""
    content = read_python_output(path) if strip_python_header else read_text(path)
    return fenced(language, content)


def read_code_block(block: MarkdownCodeBlock) -> str:
    """Read content for a Markdown code block."""
    if block.content is not None:
        return block.content.rstrip()
    if block.path is None:  # pragma: no cover - registry construction should prevent this
        msg = f"{block.label} requires either content or path"
        raise ValueError(msg)
    if block.strip_python_header:
        return read_python_output(block.path)
    return read_text(block.path)


def render_labeled_code_block(block: MarkdownCodeBlock) -> str:
    """Render one labeled Markdown code block."""
    return f"**{block.label}:**{fenced(block.language, read_code_block(block))}"


def indent_markdown(content: str, *, prefix: str = "    ") -> str:
    """Indent Markdown content for an admonition body."""
    return "\n".join(f"{prefix}{line}" if line else "" for line in content.splitlines())


def render_cli_example(*blocks: MarkdownCodeBlock) -> str:
    """Render a MkDocs example block from labeled code examples."""
    if not blocks:
        return ""
    body = "\n\n".join(render_labeled_code_block(block).strip() for block in blocks)
    return f'\n??? example "Examples"\n\n{indent_markdown(body)}\n'


def directory_files(path: Path) -> list[Path]:
    """Return stable file entries for generated directory examples."""
    return sorted(file for file in path.iterdir() if file.is_file())


def directory_tree(path: Path, *, root_name: str) -> str:
    """Render a stable text tree for generated directory examples."""
    lines = [f"{root_name}/"]
    files = directory_files(path)
    for index, file in enumerate(files):
        branch = "`--" if index == len(files) - 1 else "|--"
        lines.append(f"{branch} {file.name}")
    return "\n".join(lines)


def render_reuse_scope_tree_example() -> str:
    """Render tested reuse-scope tree output with representative files."""
    output_path = EXPECTED_MAIN / "jsonschema" / "reuse_scope_tree"
    lines = [
        "",
        "**Output with `--reuse-scope tree`:**",
        fenced("text", directory_tree(output_path, root_name="models")),
    ]
    for file in directory_files(output_path):
        content = read_python_output(file)
        lines.extend((
            f"**models/{file.name}:**",
            fenced("python", content) if content.strip() else "_No code beyond the generated header._\n",
        ))
    return "\n".join(lines)


def _file_example(
    example_id: str,
    doc: str,
    *,
    language: str,
    path: Path,
    strip_python_header: bool = False,
) -> DocsExample:
    """Build a docs example from one fixture file."""
    return DocsExample(
        example_id=example_id,
        path=DOCS / doc,
        render=lambda: render_file_example(
            language=language,
            path=path,
            strip_python_header=strip_python_header,
        ),
    )


def docs_examples() -> tuple[DocsExample, ...]:
    """Return all generated docs example sections."""
    return (
        DocsExample(
            example_id="openapi.quick-start.schema",
            path=DOCS / "openapi.md",
            render=lambda: details("api.yaml", "yaml", read_text(OPENAPI_DATA / "api.yaml")),
        ),
        _file_example(
            "openapi.quick-start.output",
            "openapi.md",
            language="python",
            path=EXPECTED_OPENAPI / "general.py",
            strip_python_header=True,
        ),
        _file_example(
            "openapi.read-only-write-only.schema",
            "openapi.md",
            language="yaml",
            path=OPENAPI_DATA / "read_only_write_only.yaml",
        ),
        _file_example(
            "openapi.read-only-write-only.output",
            "openapi.md",
            language="python",
            path=EXPECTED_OPENAPI / "read_only_write_only_all.py",
            strip_python_header=True,
        ),
        _file_example(
            "openapi.read-only-write-only-allof.schema",
            "openapi.md",
            language="yaml",
            path=OPENAPI_DATA / "read_only_write_only_allof.yaml",
        ),
        _file_example(
            "model-reuse.reuse-model.output",
            "model-reuse.md",
            language="python",
            path=EXPECTED_JSON_SCHEMA / "json_reuse_enum.py",
            strip_python_header=True,
        ),
        DocsExample(
            example_id="model-reuse.reuse-scope-tree.output",
            path=DOCS / "model-reuse.md",
            render=render_reuse_scope_tree_example,
        ),
        _file_example(
            "model-reuse.use-type-alias.schema",
            "model-reuse.md",
            language="json",
            path=JSON_SCHEMA_DATA / "reduce_duplicate_field_types.json",
        ),
        _file_example(
            "model-reuse.use-type-alias.output",
            "model-reuse.md",
            language="python",
            path=EXPECTED_JSON_SCHEMA / "reduce_duplicate_field_types.py",
            strip_python_header=True,
        ),
        DocsExample(
            example_id="cli-reference.base-options.input-model.example",
            path=DOCS / "cli-reference" / "base-options.md",
            render=lambda: render_cli_example(
                MarkdownCodeBlock(
                    "Command",
                    "bash",
                    content=(
                        "datamodel-codegen \\\n"
                        "  --input-model tests.data.python.input_model.typeddict_models:User \\\n"
                        "  --output model.py"
                    ),
                ),
                MarkdownCodeBlock(
                    "Input Model (`tests/data/python/input_model/typeddict_models.py`)",
                    "python",
                    path=INPUT_MODEL_DATA / "typeddict_models.py",
                ),
                MarkdownCodeBlock(
                    "Output",
                    "python",
                    path=EXPECTED_INPUT_MODEL / "typeddict.py",
                    strip_python_header=True,
                ),
            ),
        ),
        DocsExample(
            example_id="cli-reference.model-customization.base-class-map.example",
            path=DOCS / "cli-reference" / "model-customization.md",
            render=lambda: render_cli_example(
                MarkdownCodeBlock(
                    "Input Schema",
                    "json",
                    path=JSON_SCHEMA_DATA / "base_class_map.json",
                ),
                MarkdownCodeBlock(
                    "Output",
                    "python",
                    path=EXPECTED_JSON_SCHEMA / "base_class_map.py",
                    strip_python_header=True,
                ),
            ),
        ),
        DocsExample(
            example_id="cli-reference.template-customization.custom-template-dir.example",
            path=DOCS / "cli-reference" / "template-customization.md",
            render=lambda: render_cli_example(
                MarkdownCodeBlock(
                    "Input Schema (`api.yaml`)",
                    "yaml",
                    path=OPENAPI_DATA / "api.yaml",
                ),
                MarkdownCodeBlock(
                    "Template (`templates/pydantic/BaseModel.jinja2`)",
                    "jinja2",
                    path=TEST_DATA / "templates" / "pydantic" / "BaseModel.jinja2",
                ),
                MarkdownCodeBlock(
                    "Extra Template Data (`openapi/extra_data.json`)",
                    "json",
                    path=OPENAPI_DATA / "extra_data.json",
                ),
                MarkdownCodeBlock(
                    "Output",
                    "python",
                    path=EXPECTED_OPENAPI / "custom_template_dir.py",
                    strip_python_header=True,
                ),
            ),
        ),
        DocsExample(
            example_id="cli-reference.typing-customization.use-closed-typed-dict.example",
            path=DOCS / "cli-reference" / "typing-customization.md",
            render=lambda: render_cli_example(
                MarkdownCodeBlock(
                    "Input Schema",
                    "json",
                    path=JSON_SCHEMA_DATA / "typed_dict_closed.json",
                ),
                MarkdownCodeBlock(
                    "Output",
                    "python",
                    path=EXPECTED_JSON_SCHEMA / "typed_dict_closed.py",
                    strip_python_header=True,
                ),
            ),
        ),
    )


def replace_example(content: str, example: DocsExample) -> tuple[str, bool]:
    """Replace one generated example section in Markdown."""
    begin_marker = BEGIN_MARKER_TEMPLATE.format(example_id=example.example_id)
    end_marker = END_MARKER_TEMPLATE.format(example_id=example.example_id)
    pattern = re.compile(rf"{re.escape(begin_marker)}.*?{re.escape(end_marker)}", re.DOTALL)
    if not (match := pattern.search(content)):
        print(f"Warning: markers not found for {example.example_id} in {example.path}", file=sys.stderr)
        return content, False

    new_section = f"{begin_marker}{example.render()}{end_marker}"
    return content[: match.start()] + new_section + content[match.end() :], True


def iter_examples_by_path() -> Iterable[tuple[Path, tuple[DocsExample, ...]]]:
    """Yield docs examples grouped by Markdown file."""
    examples_by_path: dict[Path, list[DocsExample]] = {}
    for example in docs_examples():
        examples_by_path.setdefault(example.path, []).append(example)
    for path, examples in examples_by_path.items():
        yield path, tuple(examples)


def update_docs_examples(*, check: bool = False) -> int:
    """Update or check generated documentation examples."""
    changed_content_by_path = {}
    out_of_date_paths = []
    missing_markers = False
    for path, examples in iter_examples_by_path():
        content = path.read_text(encoding="utf-8")
        updated_content = content
        missing_marker_for_path = False
        for example in examples:
            updated_content, found = replace_example(updated_content, example)
            if not found:
                missing_markers = True
                missing_marker_for_path = True
        if missing_marker_for_path:
            continue
        if updated_content == content:
            continue
        if check:
            out_of_date_paths.append(path)
            continue
        changed_content_by_path[path] = updated_content

    for path in out_of_date_paths:
        print(f"Docs examples are out of date: {path}", file=sys.stderr)
    if out_of_date_paths:
        print("Run 'python scripts/build_docs_examples.py' to update.", file=sys.stderr)
    if missing_markers or out_of_date_paths:
        return 1

    if changed_content_by_path:
        for path, updated_content in changed_content_by_path.items():
            path.write_text(updated_content, encoding="utf-8")
            print(f"Updated {path}")
        return 0

    print("Docs examples are up to date.")
    return 0


def main() -> int:
    """Parse arguments and build docs examples."""
    parser = argparse.ArgumentParser(description="Build docs examples from tested fixtures")
    parser.add_argument(
        "--check",
        action="store_true",
        help="Check if docs examples would change without modifying files",
    )
    args = parser.parse_args()
    return update_docs_examples(check=args.check)


if __name__ == "__main__":
    sys.exit(main())
