"""Manifest compiler for xclif routes.

Walks a routes package once (at build/install time) and emits a
``_xclif_manifest.py`` file next to the routes package.  At runtime the
manifest is loaded by ``Cli.from_manifest()`` instead of re-walking the
filesystem, skipping ``pkgutil.walk_packages`` + ``inspect.getmembers``
overhead (~13-15 ms on Apple Silicon).

Usage
-----
From the command line::

    python -m xclif compile myapp.routes

Or programmatically::

    from xclif.compiler import compile_routes
    import myapp.routes as routes
    compile_routes(routes)
"""

from __future__ import annotations

import importlib
import inspect
import pkgutil
import types
from pathlib import Path

from xclif.command import Command


def _find_command_attr(module: types.ModuleType) -> str | None:
    """Return the attribute name of the single Command in *module*, or None.

    Raises
    ------
    ValueError
        If more than one Command is found in the module.
    """
    members = [
        (name, obj)
        for name, obj in inspect.getmembers(module, lambda x: isinstance(x, Command))
    ]
    if not members:
        return None
    if len(members) > 1:
        raise ValueError(f"Multiple commands found in module {module.__name__!r}")
    name, _ = members[0]
    return name


def compile_routes(routes: types.ModuleType, output_dir: Path | None = None) -> Path:
    """Walk *routes* and write a manifest file.

    Parameters
    ----------
    routes:
        The routes package module (e.g. ``import myapp.routes as routes``).
    output_dir:
        Directory to write ``_xclif_manifest.py`` into.  Defaults to the
        directory containing the routes package itself (i.e. sits next to it).

    Returns
    -------
    Path
        The path of the written manifest file.
    """
    if routes.__package__ is None:
        raise ImportError(
            f"Routes module {routes.__name__!r} must be part of a package"
        )

    # Validate root has exactly one Command
    root_members = inspect.getmembers(routes, lambda x: isinstance(x, Command))
    if len(root_members) == 0:
        raise ValueError(f"No commands found in root module {routes.__name__!r}")
    if len(root_members) > 1:
        raise ValueError(f"Multiple commands found in root module {routes.__name__!r}")

    root_attr = root_members[0][0]
    package_name = routes.__package__

    # Discover all sub-modules
    entries: list[tuple[str, str]] = []  # (dotted_module_name, attr_name)
    for _, mod_name, _ispkg in pkgutil.walk_packages(
        routes.__path__,
        routes.__name__ + ".",
    ):
        mod = importlib.import_module(mod_name)
        attr = _find_command_attr(mod)
        if attr is not None:
            entries.append((mod_name, attr))

    # Determine output location
    if output_dir is None:
        if routes.__file__ is None:
            raise ImportError(
                f"Cannot determine location of routes package {routes.__name__!r}"
            )
        routes_package_path = Path(routes.__file__).parent  # .../routes/
        output_dir = routes_package_path.parent  # next to routes/

    output_path = output_dir / "_xclif_manifest.py"

    # Build the manifest source
    root_path = package_name + "."
    lines: list[str] = [
        "# Generated by `xclif compile` — do not edit by hand.",
        "# Re-run `python -m xclif compile <routes_module>` after adding/removing routes.",
        "from __future__ import annotations",
        "",
        "from xclif import Cli",
        "",
        "",
        "def _build_cli(version: str | None = None) -> Cli:",
    ]

    # Import lines (inside the function so they remain lazy at module-load time
    # but are all imported when _build_cli() is called — same behaviour as
    # from_routes, but without the filesystem walk overhead)
    lines.append(f"    from {routes.__name__} import {root_attr} as _root")
    for mod_name, attr in entries:
        alias = "_" + mod_name.replace(".", "_")
        lines.append(f"    from {mod_name} import {attr} as {alias}")

    lines.append("")

    # Reuse the imported Command objects directly so all fields
    # (subcommands, implicit_options, version, etc.) are preserved.
    lines.append("    root = _root")

    # Build each sub-command, grouping by path segments
    # We need to emit add_command calls in depth-first order so parent
    # namespace commands exist before children are attached.
    # Collect (path_list, alias) pairs sorted by depth then name.
    sub_entries: list[tuple[list[str], str]] = []
    for mod_name, _ in entries:
        rel = mod_name.removeprefix(root_path)
        path = rel.split(".")
        alias = "_" + mod_name.replace(".", "_")
        sub_entries.append((path, alias))

    # Sort by depth then path so namespaces are created before their children
    sub_entries.sort(key=lambda x: (len(x[0]), x[0]))

    lines.append("")
    lines.append("    cli = Cli(root_command=root, version=version)")
    for path, alias in sub_entries:
        path_repr = repr(path)
        lines.append(f"    cli.add_command({path_repr}, {alias})")

    lines.append("    return cli")
    lines.append("")

    source = "\n".join(lines) + "\n"
    output_path.write_text(source, encoding="utf-8")
    return output_path
