#!/usr/bin/env python3
"""Bootstrap scaffold generator for H5P Sphinx extension packages.

Reads ``package-index.json`` (produced by the ``index`` command) and
scaffolds each H5P library as a Sphinx extension package inside the
H5P monorepo: ``pyproject.toml``, ``Pipfile``, ``Makefile``,
``README.md``, test infrastructure, and vendor submodules.

Usage::

    python -m byteb4rb1e.sphinxcontrib.h5p.utils.pkg bootstrap H5P.MultiChoice --target PATH
    python -m byteb4rb1e.sphinxcontrib.h5p.utils.pkg bootstrap --all --target PATH
"""
import argparse
from dataclasses import dataclass
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from byteb4rb1e.utils.argparse import CLICommand, CLI
from byteb4rb1e.utils.vcs.git import (
    GitError,
    has_submodule,
    submodule_add,
    submodule_checkout,
    submodule_update,
)
from jinja2 import Environment, PackageLoader

from byteb4rb1e.sphinxcontrib.h5p.utils.pkg.index import (
    dir_name_to_src_subpath,
    INDEX,
    load_index,
)
from byteb4rb1e.sphinxcontrib.h5p.utils.pkg.h5porg.library import (
    INDEX as LIB_INDEX,
)
from byteb4rb1e.sphinxcontrib.h5p.utils.pkg.h5porg.repository import (
    INDEX as REPO_INDEX,
)

TARGET_BASEDIR = Path("src") / "byteb4rb1e" / "sphinxcontrib"/ "h5p"

logger = logging.getLogger(__name__)

_jinja_env = None  # type: Optional[Environment]


def _get_jinja_env() -> Environment:
    """Lazily initialise and return the Jinja2 template environment."""
    global _jinja_env
    if _jinja_env is None:
        _jinja_env = Environment(
            loader=PackageLoader(
                "byteb4rb1e.sphinxcontrib.h5p.utils.pkg.bootstrap",
                "templates",
            ),
            keep_trailing_newline=True,
            lstrip_blocks=True,
            trim_blocks=True,
        )
    return _jinja_env


# -------------------------------------------------------------------
# Version resolution
# -------------------------------------------------------------------

def _semver_key(version: str) -> Tuple[int, ...]:
    """Parse a version string into a tuple for sorting.

    The library index stores versions keyed by dotted strings; the
    tuple form sorts numerically so ``"1.16.22"`` beats ``"1.9.0"``.

    >>> _semver_key("1.16.22")
    (1, 16, 22)
    >>> sorted(["1.9.0", "1.16.22", "1.16.3"], key=_semver_key)
    ['1.9.0', '1.16.3', '1.16.22']
    """
    return tuple(int(p) for p in version.split("."))


def _resolve_version(
    machine_name: str,
    requested: str,
    lib_index: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
    """Look up a version entry in the library index.

    *requested* may be:

    - empty string → latest (highest semver)
    - major.minor prefix (e.g. ``"1.16"``) → highest patch
    - exact match (e.g. ``"1.16.22"``)

    Returns ``{"ref": ..., "dependencies": ...}`` or ``None``.

    Latest match when *requested* is empty:

    >>> lib = {"H5P.TrueFalse": {"versions": {
    ...     "1.8.2": {"ref": "abc", "dependencies": {}},
    ...     "1.9.0": {"ref": "def", "dependencies": {}},
    ... }}}
    >>> _resolve_version("H5P.TrueFalse", "", lib)["ref"]
    'def'

    Major.minor prefix picks the highest matching patch:

    >>> _resolve_version("H5P.TrueFalse", "1.8", lib)["ref"]
    'abc'

    Missing library or missing version returns ``None``:

    >>> _resolve_version("H5P.Missing", "", lib) is None
    True
    >>> _resolve_version("H5P.TrueFalse", "2.0", lib) is None
    True
    """
    entry = lib_index.get(machine_name)
    if not entry:
        return None
    versions = entry.get("versions", {})
    if not versions:
        return None

    if not requested:
        # Latest: highest semver
        best = max(versions, key=_semver_key)
        return versions[best]

    # Exact match
    if requested in versions:
        return versions[requested]

    # Prefix match: find all versions starting with requested
    candidates = {
        v: data for v, data in versions.items()
        if v.startswith(requested + ".")
        or v == requested
    }
    if not candidates:
        return None
    best = max(candidates, key=_semver_key)
    return candidates[best]


def _resolve_deps(
    dependencies: Dict[str, str],
    pkg_index: Dict[str, Any],
) -> List[Dict[str, str]]:
    """Map dependency machineNames to package-index entries.

    Returns a list of ``{"pip_name": ..., "dir_name": ...}`` dicts,
    suitable for substitution into the scaffolded package's
    ``pyproject.toml`` dependencies list.  Names not in the index
    are logged and skipped (the caller can still scaffold even if
    the index is incomplete; the missing sibling just won't appear
    as a declared dep).

    >>> pkg_idx = {
    ...     "H5P.Question": {
    ...         "dir_name": "question",
    ...         "pip_name": "byteb4rb1e.sphinxcontrib.h5p.question",
    ...     },
    ...     "FontAwesome": {
    ...         "dir_name": "font_awesome",
    ...         "pip_name": "byteb4rb1e.sphinxcontrib.h5p.font_awesome",
    ...     },
    ... }
    >>> _resolve_deps(
    ...     {"H5P.Question": "1.4", "FontAwesome": "4.5"}, pkg_idx,
    ... ) == [
    ...     {"dir_name": "font_awesome",
    ...      "pip_name": "byteb4rb1e.sphinxcontrib.h5p.font_awesome"},
    ...     {"dir_name": "question",
    ...      "pip_name": "byteb4rb1e.sphinxcontrib.h5p.question"},
    ... ]
    True

    Entries not in the package index are dropped (and a warning
    logged):

    >>> _resolve_deps({"H5P.Missing": "1.0"}, pkg_idx)
    []
    """
    result = []
    for dep_name in sorted(dependencies):
        if dep_name not in pkg_index:
            logger.warning(
                "Dependency %s not in package index, skipping",
                dep_name,
            )
            continue
        names = pkg_index[dep_name]
        result.append({
            "pip_name": names["pip_name"],
            "dir_name": names["dir_name"],
        })
    return result


# -------------------------------------------------------------------
# Template rendering
# -------------------------------------------------------------------

def render(template_name: str, context: Dict[str, Any]) -> str:
    """Load and render a Jinja2 template from the templates package."""
    env = _get_jinja_env()
    tmpl = env.get_template(template_name)
    return tmpl.render(**context)


def build_context(
    machine_name: str,
    names: Dict[str, str],
    deps: List[Dict[str, str]] = (),
) -> Dict[str, Any]:
    """Build the Jinja2 template context from a package-index entry."""
    return {
        "machine_name": machine_name,
        "dir_name": names["dir_name"],
        "pip_name": names["pip_name"],
        "vendor_name": names["vendor_name"],
        "deps": list(deps),
    }


# -------------------------------------------------------------------
# File generation
# -------------------------------------------------------------------

_GENERATED_FILES = [
    ("pyproject.toml", "pyproject.toml.j2"),
    ("Pipfile", "Pipfile.j2"),
    ("Makefile", "Makefile.j2"),
    ("README.md", "README.md.j2"),
    ("tox.ini", "tox.ini.j2"),
    ("tests/unit/conftest.py", "conftest.py.j2"),
]


def scaffold_one(
    machine_name: str,
    names: Dict[str, str],
    target_dir: Path,
    mirror_url: str = "",
    ref: str = "",
    deps: List[Dict[str, str]] = (),
) -> None:
    """Scaffold a single package inside the monorepo."""
    dir_name = names["dir_name"]
    vendor_name = names["vendor_name"]
    pkg_root = target_dir / dir_name

    ctx = build_context(machine_name, names, deps=deps)

    # Ensure directory structure.  Packages use the PEP 420 namespace
    # layout so the import path matches the dotted distribution name
    # (e.g. byteb4rb1e.sphinxcontrib.h5p.true_false).  Only the leaf
    # directory carries an __init__.py; parents are implicit.
    pkg_root.mkdir(parents=True, exist_ok=True)
    (pkg_root / "vendor").mkdir(exist_ok=True)
    src_dir = pkg_root / "src" / dir_name_to_src_subpath(dir_name)
    src_dir.mkdir(parents=True, exist_ok=True)
    (pkg_root / "tests").mkdir(exist_ok=True)
    (pkg_root / "tests" / "__init__.py").touch()
    (pkg_root / "tests" / "unit").mkdir(exist_ok=True)
    (pkg_root / "tests" / "unit" / "__init__.py").touch()

    # Render and write each templated file
    for rel_path, template_name in _GENERATED_FILES:
        content = render(template_name, ctx)
        file_path = pkg_root / rel_path
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(content, encoding="utf-8")
        logger.info("Wrote %s", file_path)

    # __init__.py for the src package
    init_path = src_dir / "__init__.py"
    init_path.write_text(render("__init__.py.j2", ctx), encoding="utf-8")
    logger.info("Wrote %s", init_path)

    # Vendor submodule
    if mirror_url:
        vendor_path = "%s/vendor/%s" % (dir_name, vendor_name)
        submodule_ok = False
        if has_submodule(target_dir, vendor_path):
            logger.info(
                "Submodule %s exists, updating", vendor_path,
            )
            try:
                submodule_update(target_dir, vendor_path)
                submodule_ok = True
            except GitError as exc:
                logger.warning(
                    "Failed to update submodule %s: %s",
                    vendor_path, exc,
                )
        else:
            logger.info(
                "Adding submodule %s → %s",
                mirror_url, vendor_path,
            )
            try:
                submodule_add(
                    target_dir, mirror_url, vendor_path,
                )
                submodule_ok = True
            except GitError as exc:
                logger.warning(
                    "Failed to add submodule %s: %s",
                    vendor_path, exc,
                )
        if ref and submodule_ok:
            try:
                submodule_checkout(target_dir, vendor_path, ref)
            except GitError as exc:
                logger.warning(
                    "Failed to checkout ref %s in %s: %s",
                    ref, vendor_path, exc,
                )


def _parse_entry(entry: str) -> tuple:
    """Parse ``machineName`` or ``machineName@version``."""
    if "@" in entry:
        mn, version = entry.rsplit("@", 1)
        return mn, version
    return entry, ""


def bootstrap(
    entries: List[str],
    all_entries: bool = False,
    target_dir: Path = TARGET_BASEDIR,
    index: Path = INDEX,
    repo_index: Path = REPO_INDEX,
    lib_index: Path = LIB_INDEX,
) -> int:
    """Scaffold packages in the H5P monorepo.

    Returns exit code: 0 success, 1 bad args / missing prerequisites.
    """
    if not target_dir.is_dir():
        logger.error(
            "Target directory does not exist: %s", target_dir,
        )
        return 1

    pkg_index = load_index(index)
    if not pkg_index:
        logger.error(
            "Package index is empty or missing: %s", index,
        )
        return 1

    repo_idx = load_index(repo_index)
    lib_idx = load_index(lib_index)

    # Select entries
    if all_entries:
        selected = [(mn, names, "") for mn, names
                     in pkg_index.items()]
    else:
        if not entries:
            logger.error(
                "No entries specified. "
                "Use positional args or --all.",
            )
            return 1
        selected = []
        for entry in entries:
            mn, version = _parse_entry(entry)
            if mn not in pkg_index:
                logger.error(
                    "Unknown machine name: %s", mn,
                )
                return 1
            selected.append((mn, pkg_index[mn], version))

    if not selected:
        logger.warning("No packages to scaffold")
        return 0

    # Expand the selection to include the transitive dependency closure.
    # A content type's generated Python package declares dependencies on
    # its H5P libraries; each of those libraries must also be scaffolded
    # as a Python package so the editable install resolves locally.  The
    # closure is walked via preloadedDependencies from the library index.
    if lib_idx:
        seen = {mn for mn, _, _ in selected}
        frontier = [mn for mn, _, _ in selected]
        while frontier:
            current = frontier.pop()
            ver_data = _resolve_version(current, "", lib_idx)
            if not ver_data:
                continue
            for dep_mn in ver_data.get("dependencies", {}):
                if dep_mn in seen or dep_mn not in pkg_index:
                    continue
                seen.add(dep_mn)
                frontier.append(dep_mn)
                selected.append((dep_mn, pkg_index[dep_mn], ""))
                logger.info(
                    "Adding transitive dependency %s to scaffold set",
                    dep_mn,
                )

    for machine_name, names, version_str in selected:
        logger.info("Scaffolding %s", machine_name)
        mirror_url = repo_idx.get(
            machine_name, {},
        ).get("mirror", "")

        ref = ""
        deps = []  # type: List[Dict[str, str]]
        if lib_idx:
            ver_data = _resolve_version(
                machine_name, version_str, lib_idx,
            )
            if ver_data:
                ref = ver_data.get("ref", "")
                deps = _resolve_deps(
                    ver_data.get("dependencies", {}),
                    pkg_index,
                )
            elif version_str:
                logger.warning(
                    "Version %s not found for %s, "
                    "scaffolding without version pin",
                    version_str, machine_name,
                )

        scaffold_one(
            machine_name, names, target_dir,
            mirror_url=mirror_url, ref=ref, deps=deps,
        )

    return 0


# -------------------------------------------------------------------
# CLI
# -------------------------------------------------------------------

@dataclass
class BootstrapCommand(CLICommand):
    name: str = "bootstrap"
    help: str = "scaffold a sphinxcontrib.h5p python package"

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "entries", nargs="*",
            help=(
                "machineName or machineName@version pairs "
                "(e.g. H5P.MultiChoice@1.16)."
            ),
        )
        parser.add_argument(
            "--all", action="store_true", dest="all_entries",
            help="Scaffold all packages",
        )
        parser.add_argument(
            "--target", type=Path, default=TARGET_BASEDIR,
            help="Target monorepo base directory",
        )
        parser.add_argument(
            "--index", type=Path, default=INDEX,
            help="package index location",
        )
        parser.add_argument(
            "--repository-index", type=Path, default=REPO_INDEX,
            help="repository index location",
        )
        parser.add_argument(
            "--library-index", type=Path, default=LIB_INDEX,
            help="library index location",
        )

    def execute(self, args: Any) -> int:
        return bootstrap(
            entries=args.entries,
            all_entries=args.all_entries,
            target_dir=args.target,
            index=Path(args.index),
            repo_index=Path(args.repository_index),
            lib_index=Path(args.library_index),
        )


if __name__ == "__main__":

    cli = CLI(prog="bootstrap", description=bootstrap.__doc__)
    cmd = BootstrapCommand()
    cmd.add_arguments(cli.parser)
    cmd.execute(cli.parser.parse_args())
