# SPDX-License-Identifier: LGPL-2.1-or-later

import logging
import textwrap
from collections.abc import Sequence
from pathlib import Path
from typing import Optional

from mkosi.config import Cacheonly, Config
from mkosi.context import Context
from mkosi.distribution import detect_distribution
from mkosi.installer import PackageManager
from mkosi.installer.rpm import RpmRepository, rpm_cmd
from mkosi.log import ARG_DEBUG
from mkosi.run import CompletedProcess, run, workdir
from mkosi.util import _FILE, PathString


class Dnf(PackageManager):
    @classmethod
    def executable(cls, config: Config) -> str:
        # Allow the user to override autodetection with an environment variable
        dnf = config.finalize_environment().get("MKOSI_DNF")
        return Path(dnf or config.find_binary("dnf5") or "dnf").name

    @classmethod
    def subdir(cls, config: Config) -> Path:
        return Path("libdnf5" if cls.executable(config) == "dnf5" else "dnf")

    @classmethod
    def package_subdirs(cls, cache: Path) -> list[tuple[Path, Path]]:
        dirs = [p for p in cache.iterdir() if p.is_dir() and "-" in p.name and "mkosi" not in p.name]
        return [
            (
                # If the package cache directory is set to /var, we need to make sure we look up packages
                # where they were stored by dnf, so don't do any special handling in that case.
                d.relative_to(cache) / "packages"
                if cache.parent == Path("/var/cache")
                # Cache directories look like <repo-id>-<baseurl-hash> so let's strip off the hash to reuse
                # the same package cache directory regardless of baseurl.
                else Path("packages") / d.name[: d.name.rfind("-")],
                d.relative_to(cache) / "packages",
            )
            for d in dirs
        ]

    @classmethod
    def package_globs(cls) -> list[str]:
        return ["*.rpm"]

    @classmethod
    def scripts(cls, context: Context) -> dict[str, list[PathString]]:
        return {
            "dnf": cls.apivfs_script_cmd(context) + cls.env_cmd(context) + cls.cmd(context),
            "rpm": cls.apivfs_script_cmd(context) + rpm_cmd(),
            "mkosi-install":   ["dnf", "install"],
            "mkosi-upgrade":   ["dnf", "upgrade"],
            "mkosi-remove":    ["dnf", "remove"],
            "mkosi-reinstall": ["dnf", "reinstall"],
        }  # fmt: skip

    @classmethod
    def setup(
        cls,
        context: Context,
        repositories: Sequence[RpmRepository],
        filelists: bool = True,
        metadata_expire: Optional[str] = None,
    ) -> None:
        (context.sandbox_tree / "etc/dnf/vars").mkdir(parents=True, exist_ok=True)
        (context.sandbox_tree / "etc/yum.repos.d").mkdir(parents=True, exist_ok=True)

        config = context.sandbox_tree / "etc/dnf/dnf.conf"

        if not config.exists():
            config.parent.mkdir(exist_ok=True, parents=True)
            with config.open("w") as f:
                # Make sure we download filelists so all dependencies can be resolved.
                # See https://bugzilla.redhat.com/show_bug.cgi?id=2180842
                if cls.executable(context.config) == "dnf5" and filelists:
                    f.write("[main]\noptional_metadata_types=filelists\n")

        # The CentOS Hyperscale ships a COW plugin for dnf that's disabled by default. Let's enable it so we
        # can take advantage of faster rpm package installations.
        reflink = context.sandbox_tree / "etc/dnf/plugins/reflink.conf"
        if not reflink.exists():
            reflink.parent.mkdir(parents=True, exist_ok=True)
            reflink.write_text(
                textwrap.dedent(
                    """\
                    [main]
                    enabled=1
                    """
                )
            )

        # The versionlock plugin will fail if enabled without a configuration file so lets' write a noop
        # configuration file to make it happy which can be overridden by users.
        versionlock = context.sandbox_tree / "etc/dnf/plugins/versionlock.conf"
        if not versionlock.exists():
            versionlock.parent.mkdir(parents=True, exist_ok=True)
            versionlock.write_text(
                textwrap.dedent(
                    """\
                    [main]
                    enabled=0
                    locklist=/dev/null
                    """
                )
            )

        # TODO: librepo rejects valid repository metadata GPG signatures with gpgme 2.1 ("Bad GPG
        # signature"): https://github.com/rpm-software-management/librepo/issues/376
        # This happens a lot when building a distro which signs its repos (CentOS) on newer distros like
        # Arch, Debian testing, or openSUSE Tumbleweed, with their newer gpgme.
        # Thus until this is fixed, only validate repo GPG signatures when the distributions match. We can
        # assume that distros test their own dnf repos :-)
        # Revert once librepo gets fixed and into the distros.
        repo_gpgcheck_broken = detect_distribution(context.config.tools())[0] != context.config.distribution
        if repo_gpgcheck_broken:
            logging.info(
                "Disabling repository metadata GPG signature check: "
                "https://github.com/rpm-software-management/librepo/issues/376"
            )

        repofile = context.sandbox_tree / "etc/yum.repos.d/mkosi.repo"
        if not repofile.exists():
            repofile.parent.mkdir(exist_ok=True, parents=True)
            with repofile.open("w") as f:
                for repo in repositories:
                    f.write(
                        textwrap.dedent(
                            f"""\
                            [{repo.id}]
                            name={repo.id}
                            {repo.url}
                            gpgcheck=1
                            repo_gpgcheck={int(repo.repo_gpgcheck and not repo_gpgcheck_broken)}
                            enabled={int(repo.enabled)}
                            """
                        )
                    )

                    if repo.sslcacert:
                        f.write(f"sslcacert={repo.sslcacert}\n")
                    if repo.sslclientcert:
                        f.write(f"sslclientcert={repo.sslclientcert}\n")
                    if repo.sslclientkey:
                        f.write(f"sslclientkey={repo.sslclientkey}\n")
                    if repo.priority:
                        f.write(f"priority={repo.priority}\n")
                    if metadata_expire:
                        f.write(f"metadata_expire={metadata_expire}\n")

                    for i, url in enumerate(repo.gpgurls):
                        f.write("gpgkey=" if i == 0 else len("gpgkey=") * " ")
                        f.write(f"{url}\n")

                    f.write("\n")

    @classmethod
    def finalize_environment(cls, context: Context) -> dict[str, str]:
        return super().finalize_environment(context) | {
            "RPM_FORCE_DEBIAN": "1",
        }

    @classmethod
    def cmd(
        cls,
        context: Context,
        cached_metadata: bool = True,
    ) -> list[PathString]:
        dnf = cls.executable(context.config)

        cmdline: list[PathString] = [
            dnf,
            "--assumeyes",
            "--best",
            f"--releasever={context.config.release}",
            "--installroot=/buildroot",
            "--setopt=keepcache=1",
            "--setopt=logdir=/var/log",
            f"--setopt=cachedir=/var/cache/{cls.subdir(context.config)}",
            f"--setopt=install_weak_deps={int(context.config.with_recommends)}",
            "--setopt=check_config_file_age=0",
            "--setopt=persistdir=/buildroot/var/lib/dnf",
        ]

        if plugins := context.config.environment.get("MKOSI_DNF_DISABLE_PLUGINS"):
            cmdline += [f"--disableplugin={plugins}"]
        if plugins := context.config.environment.get("MKOSI_DNF_ENABLE_PLUGINS"):
            cmdline += [f"--enableplugin={plugins}"]

        if ARG_DEBUG.get():
            cmdline += ["--setopt=debuglevel=10"]

        if not context.config.repository_key_check:
            cmdline += ["--nogpgcheck"]

        if context.config.repositories:
            opt = "--enable-repo" if dnf == "dnf5" else "--enablerepo"
            cmdline += [f"{opt}={repo}" for repo in context.config.repositories]

        if context.config.cacheonly == Cacheonly.always:
            cmdline += ["--cacheonly"]
        elif cached_metadata:
            cmdline += ["--setopt=metadata_expire=never"]
            if dnf == "dnf5":
                cmdline += ["--setopt=cacheonly=metadata"]

        if not context.config.architecture.is_native():
            cmdline += [f"--forcearch={cls.architecture(context)}"]

        if not context.config.with_docs:
            cmdline += ["--no-docs" if dnf == "dnf5" else "--nodocs"]

        if dnf == "dnf5":
            cmdline += ["--use-host-config"]
        else:
            cmdline += [
                "--config=/etc/dnf/dnf.conf",
                "--setopt=reposdir=/etc/yum.repos.d",
                "--setopt=varsdir=/etc/dnf/vars",
            ]

        if context.config.proxy_url:
            cmdline += [f"--setopt=proxy={context.config.proxy_url}"]
        if context.config.proxy_peer_certificate:
            cmdline += ["--setopt=proxy_sslcacert=/proxy.cacert"]
        if context.config.proxy_client_certificate:
            cmdline += ["--setopt=proxy_sslclientcert=/proxy.clientcert"]
        if context.config.proxy_client_key:
            cmdline += ["--setopt=proxy_sslclientkey=/proxy.clientkey"]

        return cmdline

    @classmethod
    def invoke(
        cls,
        context: Context,
        operation: str,
        arguments: Sequence[str] = (),
        *,
        apivfs: bool = False,
        stdout: _FILE = None,
        cached_metadata: bool = True,
    ) -> CompletedProcess:
        try:
            return run(
                cls.cmd(context, cached_metadata=cached_metadata) + [operation, *arguments],
                sandbox=cls.sandbox(context, apivfs=apivfs),
                env=cls.finalize_environment(context),
                stdout=stdout,
            )
        finally:
            # dnf interprets the log directory relative to the install root so there's nothing we can do but
            # to remove the log files from the install root afterwards.
            if (context.root / "var/log").exists():
                for p in (context.root / "var/log").iterdir():
                    if any(p.name.startswith(prefix) for prefix in ("dnf", "hawkey", "yum")):
                        p.unlink()

    @classmethod
    def install(
        cls,
        context: Context,
        packages: Sequence[str],
        *,
        apivfs: bool = True,
        allow_downgrade: bool = False,
    ) -> None:
        arguments = []

        if allow_downgrade and Dnf.executable(context.config) == "dnf5":
            arguments += ["--allow-downgrade"]

        arguments += [*packages]

        cls.invoke(context, "install", arguments, apivfs=apivfs)

    @classmethod
    def remove(cls, context: Context, packages: Sequence[str]) -> None:
        cls.invoke(context, "remove", packages, apivfs=True)

    @classmethod
    def sync(cls, context: Context, force: bool, arguments: Sequence[str] = ()) -> None:
        cls.invoke(
            context,
            "makecache",
            arguments=[
                *(["--refresh"] if force else []),
                # Some distros enable this, which is annoying: despite failed GPG verification, sync() would
                # succeed; the later package installation then fails on the unusable cache. Fail right here
                # to make the root cause visible.
                "--setopt=skip_if_unavailable=0",
                *arguments,
            ],
            cached_metadata=False,
        )

    @classmethod
    def createrepo(cls, context: Context) -> None:
        run(
            ["createrepo_c", workdir(context.repository)],
            sandbox=context.sandbox(options=["--bind", context.repository, workdir(context.repository)]),
        )

        (context.sandbox_tree / "etc/yum.repos.d/mkosi-local.repo").write_text(
            textwrap.dedent(
                """\
                [mkosi]
                name=mkosi
                baseurl=file:///repository
                gpgcheck=0
                metadata_expire=never
                priority=10
                """
            )
        )

        cls.sync(context, force=True, arguments=["--disablerepo=*", "--enablerepo=mkosi"])
