#!/usr/bin/env python3
"""Package index: naming functions and index I/O.

Pure functions that convert between the various name forms used in the
H5P packaging pipeline, plus helpers to load, save, and build the
package index (``data/package-index.json``).

Name forms
----------

=========== ======================================
Form        Example (``h5p-multi-choice``)
=========== ======================================
repo_name   ``h5p-multi-choice``
dir_name    ``multi_choice``
pip_name    ``byteb4rb1e.sphinxcontrib.h5p.multi_choice``
import_name ``byteb4rb1e.sphinxcontrib.h5p.multi_choice``
vendor_name ``h5p-multi-choice``
=========== ======================================

Rule: strip the ``h5p-`` prefix from *repo_name* (if present), then
replace ``-`` with ``_``.  Packages use PEP 420 namespace layout so
*import_name* equals *pip_name* by convention; the two are kept
semantically distinct (dist vs. module identity).
"""
import argparse
from dataclasses import dataclass
import json
import logging
from pathlib import Path
from typing import Any, Dict

from byteb4rb1e.utils.vcs import git

from byteb4rb1e.utils.argparse import CLICommand
from byteb4rb1e.sphinxcontrib.h5p.utils.pkg.h5porg.repository import (
    INDEX as REPO_INDEX,
)

logger = logging.getLogger(__name__)

INDEX = Path("data") / "package-index.json"

_PREFIX = "h5p-"
_NAMESPACE = "byteb4rb1e.sphinxcontrib.h5p"


# -------------------------------------------------------------------
# Naming functions
# -------------------------------------------------------------------

def repo_name_to_dir_name(repo_name: str) -> str:
    """Convert a repository name to a Python-safe directory name.

    Strips the ``h5p-`` prefix (if present) and replaces hyphens with
    underscores.  The result doubles as the leaf directory under the
    H5P monorepo and the final segment of the package's dotted
    distribution name.

    Prefix-carrying H5P content repo:

    >>> repo_name_to_dir_name("h5p-multi-choice")
    'multi_choice'

    Vendored asset repo without the prefix (kept as-is, hyphens swapped):

    >>> repo_name_to_dir_name("font-awesome")
    'font_awesome'

    The prefix only strips once, even when the remainder happens to
    start with another ``h5p-``:

    >>> repo_name_to_dir_name("h5p-h5p-widget")
    'h5p_widget'
    """
    name = repo_name
    if name.startswith(_PREFIX):
        name = name[len(_PREFIX):]
    return name.replace("-", "_")


def dir_name_to_src_subpath(dir_name: str) -> Path:
    """Filesystem path (under ``src/``) for a package's namespace layout.

    Generated packages use PEP 420 implicit namespaces, so the full
    path under ``src/`` mirrors the dotted namespace prefix plus the
    leaf directory name.

    >>> dir_name_to_src_subpath("true_false").as_posix()
    'byteb4rb1e/sphinxcontrib/h5p/true_false'

    The returned object is a :class:`~pathlib.Path`, composable with
    the package root:

    >>> (Path("packages/true_false/src")
    ...  / dir_name_to_src_subpath("true_false")
    ...  / "__init__.py").as_posix()
    'packages/true_false/src/byteb4rb1e/sphinxcontrib/h5p/true_false/__init__.py'
    """
    return Path(*_NAMESPACE.split("."), dir_name)


def dir_name_to_import_name(dir_name: str) -> str:
    """Python import name for a package (follows the namespace path).

    Distinct from ``pip_name`` (PyPA distribution name) even though the
    two coincide for the packages scaffolded by this tool.  Generated
    code uses this form whenever it needs to write out a Python
    import statement.

    >>> dir_name_to_import_name("true_false")
    'byteb4rb1e.sphinxcontrib.h5p.true_false'

    The subpath from :func:`dir_name_to_src_subpath` and the dotted
    import name are the same structure expressed in different
    syntaxes, and round-trip via ``str.replace``:

    >>> subpath = dir_name_to_src_subpath("true_false").as_posix()
    >>> subpath.replace("/", ".") == dir_name_to_import_name("true_false")
    True
    """
    return "%s.%s" % (_NAMESPACE, dir_name)


def repo_name_to_pip_name(repo_name: str) -> str:
    """Convert a repository name to a pip-installable package name.

    The PyPA *distribution* name — what you write in ``pyproject.toml``
    under ``[project].name`` or type after ``pip install``.  For the
    scaffolded packages this string equals the Python import name
    (see :func:`dir_name_to_import_name`), because every generated
    package uses the PEP 420 namespace layout.

    >>> repo_name_to_pip_name("h5p-multi-choice")
    'byteb4rb1e.sphinxcontrib.h5p.multi_choice'
    >>> repo_name_to_pip_name("font-awesome")
    'byteb4rb1e.sphinxcontrib.h5p.font_awesome'
    """
    return "%s.%s" % (_NAMESPACE, repo_name_to_dir_name(repo_name))


def derive_names(repo_name: str) -> dict:
    """Derive all naming forms from a repo name.

    Packages use the PEP 420 namespace layout, so ``pip_name`` doubles
    as the Python import name.  The filesystem src path is computed at
    use time from ``_NAMESPACE.split('.') + [dir_name]``; keeping it
    out of the dict avoids storing derivable state in
    ``package-index.json``.

    >>> derive_names("h5p-true-false") == {
    ...     "dir_name": "true_false",
    ...     "pip_name": "byteb4rb1e.sphinxcontrib.h5p.true_false",
    ...     "vendor_name": "h5p-true-false",
    ... }
    True

    A non-prefixed vendor asset keeps the repo slug verbatim as
    ``vendor_name`` and drops the hyphens for ``dir_name``:

    >>> derive_names("font-awesome")["vendor_name"]
    'font-awesome'
    >>> derive_names("font-awesome")["dir_name"]
    'font_awesome'
    """
    return {
        "dir_name": repo_name_to_dir_name(repo_name),
        "pip_name": repo_name_to_pip_name(repo_name),
        "vendor_name": repo_name,
    }


# -------------------------------------------------------------------
# Index I/O
# -------------------------------------------------------------------

def load_index(path: Path) -> Dict[str, Any]:
    """Load a JSON index file.  Returns empty dict if missing."""
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    return {}


def save_index(index: Dict[str, Any], path: Path) -> None:
    """Write a JSON index file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(index, f, indent=4, ensure_ascii=False)


# -------------------------------------------------------------------
# Build
# -------------------------------------------------------------------

def index(
    repo_index_path: Path = REPO_INDEX,
    output_path: Path = INDEX,
) -> int:
    """Build the package index from the repository index.

    Reads ``repository-index.json`` (keyed by machineName with
    ``origin`` and ``mirror`` fields), extracts the repo name from
    each mirror URL, derives all naming forms, and writes
    ``package-index.json``.

    Returns 0 on success, 1 on error.
    """
    repo_index = load_index(repo_index_path)
    if not repo_index:
        logger.error(
            "Repository index is empty or missing: %s",
            repo_index_path,
        )
        return 1

    pkg_index = {}  # type: Dict[str, Any]
    for machine_name, entry in repo_index.items():
        mirror_url = entry.get("mirror", "")
        if not mirror_url:
            logger.warning(
                "No mirror URL for %s, skipping", machine_name,
            )
            continue
        repo_name = git.parse_repo_name(mirror_url)
        pkg_index[machine_name] = derive_names(repo_name)

    save_index(pkg_index, output_path)
    logger.info(
        "Wrote %d entries to %s", len(pkg_index), output_path,
    )
    return 0


@dataclass
class IndexCommand(CLICommand):
    """Build the package index from the repository index."""

    name: str = "index"
    help: str = "build package index from repository index"

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument(
            "--repository-index",
            type=Path,
            default=str(REPO_INDEX),
            help="Path to repository-index.json",
        )
        parser.add_argument(
            "--output",
            type=Path,
            default=str(INDEX),
            help="Output path for package-index.json",
        )

    def execute(self, args: Any) -> int:
        return index(
            repo_index_path=Path(args.repository_index),
            output_path=Path(args.output),
        )


if __name__ == "__main__":

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