# Copyright 2023 Oliver Smith
# SPDX-License-Identifier: GPL-3.0-or-later
"""Functions that work with pmaports.

See also:
- pmb/helpers/repo.py (work with binary package repos)
- pmb/helpers/package.py (work with both)
"""

from pmb.core.context import get_context
from pmb.core.arch import Arch
from pmb.core.pkgrepo import pkgrepo_iter_package_dirs
from pmb.helpers import logging
from pathlib import Path
from typing import overload, Any, Literal
from pmb.types import Apkbuild, WithExtraRepos

from pmb.meta import Cache
import pmb.parse


@Cache("with_extra_repos")
def _find_apkbuilds(with_extra_repos: WithExtraRepos = "default") -> dict[str, Path]:
    apkbuilds = {}
    for package in pkgrepo_iter_package_dirs(with_extra_repos=with_extra_repos):
        pkgname = package.name
        if pkgname in apkbuilds:
            raise RuntimeError(
                f"Package {pkgname} found in multiple aports "
                "subfolders. Please put it only in one folder."
            )
        apkbuilds[pkgname] = package / "APKBUILD"

    # Sort dictionary so we don't need to do it over and over again in
    # get_list()
    apkbuilds = dict(sorted(apkbuilds.items()))
    return apkbuilds


def get_list() -> list[str]:
    """:returns: list of all pmaport pkgnames (["hello-world", ...])"""
    return list(_find_apkbuilds().keys())


def guess_main_dev(subpkgname: str) -> Path | None:
    """Check if a package without "-dev" at the end exists in pmaports or not, and log the appropriate message.

    Don't call this function directly, use guess_main() instead.

    :param subpkgname: subpackage name, must end in "-dev"
    :returns: full path to the pmaport or None
    """
    pkgname = subpkgname[:-4]
    path = _find_apkbuilds().get(pkgname)
    if path:
        logging.verbose(
            subpkgname + ": guessed to be a subpackage of " + pkgname + " (just removed '-dev')"
        )
        return path.parent

    logging.verbose(
        subpkgname
        + ": guessed to be a subpackage of "
        + pkgname
        + ", which we can't find in pmaports, so it's probably in"
        " Alpine"
    )
    return None


def guess_main_cross(subpkgname: str) -> Path | None:
    """Check if a subpackage that is part of the cross toolchain is in pmaports or not, and log the appropriate message.

    Don't call this function directly, use guess_main() instead.

    :param subpkgname: subpackage name
    :returns: full path to the pmaport or None
    """
    # If it contains -dev-, assume the parent package is the same, without the infix
    if "-dev-" in subpkgname:
        pkgname = subpkgname.replace("-dev-", "-")
    else:
        pkgname = subpkgname.replace("g++", "gcc")

    path = _find_apkbuilds().get(pkgname)
    if path:
        logging.verbose(subpkgname + ": guessed to be a subpackage of " + pkgname)
        return path.parent

    logging.verbose(
        subpkgname
        + ": guessed to be a subpackage of "
        + pkgname
        + ", which we can't find in pmaports, so it's probably in"
        " Alpine"
    )
    return None


def guess_main(subpkgname: str) -> Path | None:
    """Find the main package by assuming it is a prefix of the subpkgname.

    We do that, because in some APKBUILDs the subpkgname="" variable gets
    filled with a shell loop and the APKBUILD parser in pmbootstrap can't
    parse this right. (Intentionally, we don't want to implement a full shell
    parser.)

    :param subpkgname: subpackage name (e.g. "u-boot-some-device")
    :returns: * full path to the aport, e.g.:
                "/home/user/code/pmbootstrap/aports/main/u-boot"
              * None when we couldn't find a main package
    """
    # Packages ending in -dev: just assume that the originating aport has the
    # same pkgname, except for the -dev at the end. If we use the other method
    # below on subpackages, we may end up with the wrong package. For example,
    # if something depends on plasma-framework-dev, and plasma-framework is in
    # Alpine, but plasma is in pmaports, then the cutting algorithm below would
    # pick plasma instead of plasma-framework.
    if subpkgname.endswith("-dev"):
        return guess_main_dev(subpkgname)

    # cross/* packages have a bunch of subpackages that do not have the main
    # package name as a prefix (i.e. g++-*). Further, the -dev check fails here
    # since the name ends with the name of the architecture.
    if any(subpkgname.endswith("-" + str(arch)) for arch in Arch.supported()):
        return guess_main_cross(subpkgname)

    # Iterate until the cut up subpkgname is gone
    words = subpkgname.split("-")
    while len(words) > 1:
        # Remove one dash-separated word at a time ("a-b-c" -> "a-b")
        words.pop()
        pkgname = "-".join(words)

        # Look in pmaports
        path = _find_apkbuilds().get(pkgname)
        if path:
            logging.verbose(subpkgname + ": guessed to be a subpackage of " + pkgname)
            return path.parent

    return None


def _find_package_in_apkbuild(package: str, path: Path) -> bool:
    """Look through subpackages and all provides to see if the APKBUILD at the specified path
    contains (or provides) the specified package.

    :param package: The package to search for
    :param path: The path to the apkbuild
    :return: True if the APKBUILD contains or provides the package
    """
    apkbuild = pmb.parse.apkbuild(path)

    # Subpackages
    if package in apkbuild["subpackages"]:
        return True

    # Search for provides in both package and subpackages
    apkbuild_pkgs = [apkbuild, *apkbuild["subpackages"].values()]
    for apkbuild_pkg in apkbuild_pkgs:
        if not apkbuild_pkg:
            continue

        # Provides (cut off before equals sign for entries like
        # "mkbootimg=0.0.1")
        for provides_i in apkbuild_pkg["provides"]:
            # Ignore provides without version, they shall never be
            # automatically selected
            if "=" not in provides_i:
                continue

            if package == provides_i.split("=", 1)[0]:
                return True

    return False


def show_pkg_not_found_systemd_hint(package: str, with_extra_repos: WithExtraRepos) -> None:
    """Check if a package would be found if systemd was enabled and display a
    hint about it."""

    if with_extra_repos != "default" or pmb.config.other.is_systemd_selected():
        return

    if find(package, False, with_extra_repos="enabled"):
        logging.info(
            f"NOTE: The package '{package}' exists in extra-repos/systemd, but systemd is currently disabled"
        )


@overload
def find(
    package: str,
    must_exist: Literal[True] = ...,
    subpackages: bool = ...,
    with_extra_repos: WithExtraRepos = ...,
) -> Path: ...


@overload
def find(
    package: str,
    must_exist: bool = ...,
    subpackages: bool = ...,
    with_extra_repos: WithExtraRepos = ...,
) -> Path | None: ...


@Cache("package", "subpackages", "with_extra_repos")
def find(
    package: str,
    must_exist: bool = True,
    subpackages: bool = True,
    with_extra_repos: WithExtraRepos = "default",
) -> Path | None:
    """Find the directory in pmaports that provides a package or subpackage.
    If you want the parsed APKBUILD instead, use pmb.helpers.pmaports.get().

    :param must_exist: Raise an exception, when not found
    :param subpackages: set to False as speed optimization, if you know that
                        the package is not a subpackage of another package
                        (i.e. looking for UI packages for "pmbootstrap init").
                        If a previous search with subpackages=True has found
                        the package already, it will still be returned as
                        cached result.
    :returns: the full path to the package's dir in pmaports
    """
    # Try to get a cached result first (we assume that the aports don't change
    # in one pmbootstrap call)
    ret: Path | None = None
    # Sanity check
    if "*" in package:
        raise RuntimeError("Invalid pkgname: " + package)

    # Try to find an APKBUILD with the exact pkgname we are looking for
    path = _find_apkbuilds(with_extra_repos).get(package)
    if path:
        logging.verbose(f"{package}: found apkbuild: {path}")
        ret = path.parent
    elif subpackages:
        # No luck, take a guess what APKBUILD could have the package we are
        # looking for as subpackage
        guess = guess_main(package)
        if guess:
            # Parse the APKBUILD and verify if the guess was right
            if _find_package_in_apkbuild(package, guess / "APKBUILD"):
                ret = guess

        if not guess or (guess and not ret):
            # Otherwise parse all APKBUILDs (takes time!), is the
            # package we are looking for a subpackage of any of those?
            for path_current in _find_apkbuilds().values():
                if _find_package_in_apkbuild(package, path_current):
                    ret = path_current.parent
                    break

        # If we still didn't find anything, as last resort: assume our
        # initial guess was right and the APKBUILD parser just didn't
        # find the subpackage in there because it is behind shell logic
        # that we don't parse.
        if not ret:
            ret = guess

    # Crash when necessary
    if ret is None and must_exist:
        show_pkg_not_found_systemd_hint(package, with_extra_repos)
        raise RuntimeError(f"Could not find package '{package}' in pmaports")

    return ret


def find_optional(package: str) -> Path | None:
    try:
        return find(package)
    except RuntimeError:
        return None


# The only caller with subpackages=False is ui.check_option()
@Cache("pkgname", "with_extra_repos", subpackages=True)
def get_with_path(
    pkgname: str,
    must_exist: bool = True,
    subpackages: bool = True,
    with_extra_repos: WithExtraRepos = "default",
) -> tuple[Path | None, Apkbuild | None]:
    """Find and parse an APKBUILD file.

    Run 'pmbootstrap apkbuild_parse hello-world' for a full output example.
    Relevant variables are defined in pmb.config.apkbuild_attributes.

    :param pkgname: the package name to find
    :param must_exist: raise an exception when it can't be found
    :param subpackages: also search for subpackages with the specified
        names (slow! might need to parse all APKBUILDs to find it)
    :param with_extra_repos: use extra repositories (e.g. systemd) when
        searching for the package

    :returns: relevant variables from the APKBUILD as dictionary, e.g.:
                  { "pkgname": "hello-world",
                  "arch": ["all"],
                  "pkgrel": "4",
                  "pkgrel": "1",
                  "options": [],
                  ... }
    """
    pkgname = pmb.helpers.package.remove_operators(pkgname)
    pmaport = find(pkgname, must_exist, subpackages, with_extra_repos)
    if pmaport:
        return pmaport, pmb.parse.apkbuild(pmaport / "APKBUILD")
    return None, None


@overload
def get(
    pkgname: str,
    must_exist: Literal[True] = ...,
    subpackages: bool = ...,
    with_extra_repos: WithExtraRepos = ...,
) -> Apkbuild: ...


@overload
def get(
    pkgname: str,
    must_exist: bool = ...,
    subpackages: bool = ...,
    with_extra_repos: WithExtraRepos = ...,
) -> Apkbuild | None: ...


def get(
    pkgname: str,
    must_exist: bool = True,
    subpackages: bool = True,
    with_extra_repos: WithExtraRepos = "default",
) -> Apkbuild | None:
    return get_with_path(pkgname, must_exist, subpackages, with_extra_repos)[1]


def find_providers(provide: str, default: list[str]) -> list[tuple[Any, Any]]:
    """Search for providers of the specified (virtual) package in pmaports.

    Note: Currently only providers from a single APKBUILD are returned.

    :param provide: the (virtual) package to search providers for
    :param default: the _pmb_default to look through for defaults
    :returns: tuple list (pkgname, apkbuild_pkg) with providers, sorted by
              provider_priority. The provider with the highest priority
              (which would be selected by default) comes first.
    """

    providers = {}

    apkbuild = get(provide)
    for subpkgname, subpkg in apkbuild["subpackages"].items():
        for provides in subpkg["provides"]:
            # Strip provides version (=$pkgver-r$pkgrel)
            if provides.split("=", 1)[0] == provide:
                if subpkgname in default:
                    subpkg["provider_priority"] = 999999
                providers[subpkgname] = subpkg

    return sorted(providers.items(), reverse=True, key=lambda p: p[1].get("provider_priority", 0))


def get_repo(pkgname: str) -> str | None:
    """Get the repository folder of an aport.

    :pkgname: package name
    :returns: * None if pkgname is not in extra-repos/
              * "systemd" if the pkgname is in extra-repos/systemd/
    """
    aport: Path
    aport = find(pkgname)

    if aport.parent.parent.name == "extra-repos":
        return aport.parent.name

    return None


def check_arches(arches: list[str], arch: Arch) -> bool:
    """Check if building for a certain arch is allowed.

    :param arches: list of all supported arches, as it can be found in the
        arch="" line of APKBUILDS (including all, noarch, !arch, ...).
        For example: ["x86_64", "x86", "!armhf"]

    :param arch: the architecture to check for

    :returns: True when building is allowed, False otherwise
    """
    if f"!{arch}" in arches:
        return False
    for value in [str(arch), "all", "noarch"]:
        if value in arches:
            return True
    return False


def get_channel_new(channel: str) -> str:
    """Translate legacy channel names to the new ones.

    Legacy names are still supported for compatibility with old branches (pmb#2015).
    :param channel: name as read from pmaports.cfg or channels.cfg, like "edge", "v21.03" etc.,
    or potentially a legacy name like "stable".

    :returns: name in the new format, e.g. "edge" or "v21.03"
    """
    legacy_cfg = pmb.config.pmaports_channels_legacy
    if channel in legacy_cfg:
        ret = legacy_cfg[channel]
        logging.verbose(f"Legacy channel '{channel}' translated to '{ret}'")
        return ret
    return channel


def require_bootstrap_error(repo: str, arch: Arch, trigger_str: str) -> None:
    """
    Tell the user that they need to do repo_bootstrap, with some context.

    :param repo: which repository
    :param arch: for which architecture
    :param trigger_str: message for the user to understand what caused this
    """
    logging.info(
        f"ERROR: Trying to {trigger_str} with {repo} enabled, but the"
        f" {repo} repo needs to be bootstrapped first."
    )
    raise RuntimeError(f"Run 'pmbootstrap repo_bootstrap {repo} --arch={arch}' and then try again.")


def require_bootstrap(arch: Arch, trigger_str: str) -> None:
    """
    Check if repo_bootstrap was done, if any is needed.

    :param arch: for which architecture
    :param trigger_str: message for the user to understand what caused this
    """
    if pmb.config.other.is_systemd_selected(get_context().config):
        pmb.helpers.repo.update(arch)
        pkg = pmb.parse.apkindex.package("postmarketos-base-systemd", arch, False)
        if not pkg:
            require_bootstrap_error("systemd", arch, trigger_str)
