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

import dataclasses
import json
import os
import subprocess
from collections.abc import Sequence
from pathlib import Path

from mkosi.config import Cacheonly, Config
from mkosi.context import Context
from mkosi.installer import PackageManager
from mkosi.run import CompletedProcess, run, workdir
from mkosi.sandbox import umask
from mkosi.tree import rmtree
from mkosi.util import _FILE, PathString, copyfile, flatten


@dataclasses.dataclass(frozen=True)
class ApkRepository:
    url: str


class Apk(PackageManager):
    @classmethod
    def executable(cls, config: Config) -> str:
        return "apk"

    @classmethod
    def subdir(cls, config: Config) -> Path:
        return Path("apk")

    @classmethod
    def package_subdirs(cls, cache: Path) -> list[tuple[Path, Path]]:
        return [(Path("."), Path("."))]

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

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

    @classmethod
    def setup(cls, context: Context, repositories: Sequence[ApkRepository]) -> None:
        config = context.sandbox_tree / "etc/apk/repositories"
        if config.exists():
            return

        config.parent.mkdir(exist_ok=True, parents=True)
        config.write_text("\n".join(repo.url for repo in repositories) + "\n")

    @classmethod
    def finalize_environment(cls, context: Context) -> dict[str, str]:
        return super().finalize_environment(context) | {
            # apk requires SHA1 support for signature verification, and this is disabled in the default
            # crypto-policies for Fedora/RH/SuSE. This variable is set to re-enable SHA1 support on these
            # distributions.
            # Also see: https://gitlab.alpinelinux.org/alpine/apk-tools/-/issues/11139#note_542183
            "OPENSSL_ENABLE_SHA1_SIGNATURES": "1",
        }

    @classmethod
    def cmd(
        cls,
        context: Context,
        cached_metadata: bool = True,
        allow_untrusted: bool = False,
    ) -> list[PathString]:
        return [
            "apk",
            "--root", "/buildroot",
            "--cache-packages",
            "--cache-dir", "/var/cache/apk",
            "--cache-predownload",
            "--arch", cls.architecture(context),
            "--no-interactive",
            "--preserve-env",
            "--keys-dir", "/etc/apk/keys",
            "--repositories-file", "/etc/apk/repositories",
            *(["--allow-untrusted"] if allow_untrusted or not context.config.repository_key_check else []),
            *(["--cache-max-age", "999999999"] if cached_metadata else []),
            *(["--no-network"] if context.config.cacheonly == Cacheonly.always else []),
        ]  # fmt: skip

    @classmethod
    def invoke(
        cls,
        context: Context,
        operation: str,
        arguments: Sequence[str] = (),
        *,
        apivfs: bool = False,
        stdout: _FILE = None,
        cached_metadata: bool = True,
        allow_untrusted: bool = False,
    ) -> CompletedProcess:
        return run(
            cls.cmd(
                context,
                cached_metadata=cached_metadata,
                allow_untrusted=allow_untrusted,
            )
            + [operation, *arguments],
            sandbox=cls.sandbox(context, apivfs=apivfs),
            env=cls.finalize_environment(context),
            stdout=stdout,
        )

    @classmethod
    def install(
        cls,
        context: Context,
        packages: Sequence[str],
        *,
        apivfs: bool = True,
        allow_downgrade: bool = False,
    ) -> None:
        # grub's /boot trigger execs update-grub, which runs grub-probe against the buildroot where it
        # cannot find a device for / and fails the whole apk transaction. mkosi manages the bootloader
        # configuration itself, so use the trigger's own opt-out during installation, in the same vein
        # as the policy-rc.d file we install on Debian.
        updategrubconf = context.root / "etc/update-grub.conf"
        preexisting = updategrubconf.exists()
        if not preexisting:
            with umask(~0o755):
                updategrubconf.parent.mkdir(parents=True, exist_ok=True)
            with umask(~0o644):
                updategrubconf.write_text("disable_trigger=1\n")

        cls.invoke(
            context,
            "add",
            [
                "--initdb",
                "--upgrade",
                *packages,
            ],
            apivfs=apivfs,
        )  # fmt: skip

        if not preexisting:
            updategrubconf.unlink()

    @classmethod
    def remove(cls, context: Context, packages: Sequence[str]) -> None:
        # Match against both name and provides so that packages specified
        # by a provider name are also found.
        # apk query --format json outputs a JSON array of package objects,
        # or an empty array if nothing matches.
        result = cls.invoke(
            context,
            "query",
            [
                "--installed",
                "--format", "json",
                "--match", "name,provides",
                "--fields", "name,provides",
                *packages,
            ],
            apivfs=True,
            stdout=subprocess.PIPE,
        )  # fmt: skip

        # Build a lookup set from both real package names and their provides entries
        # (stripped of version suffixes) so we can match against the original input names.
        installed: set[str] = set()
        for pkg in json.loads(result.stdout or "[]"):
            installed.add(pkg["name"])
            installed.update(p.split("=", 1)[0] for p in pkg.get("provides", []))

        remove = [p for p in packages if p in installed]
        if remove:
            cls.invoke(context, "del", remove, apivfs=True)

    @classmethod
    def sync(cls, context: Context, force: bool) -> None:
        # Updating the cache requires an initialized apk database but we don't want to touch the image root
        # directory as it may be a mount point. Use a throwaway directory instead.
        tmp = context.workspace / "apk-sync-root"
        tmp.mkdir(exist_ok=True)
        # When repository_key_fetch is enabled the trusted keys aren't installed yet, so we have to allow
        # untrusted indexes during sync. The keys are fetched and trusted by the distribution's keyring()
        # afterwards, before any actual package install.
        cmd = cls.cmd(context, allow_untrusted=context.config.repository_key_fetch)
        options = [
            "--bind", tmp, "/buildroot",
            *cls.mounts(context),
            *cls.options(root=tmp, apivfs=False)
        ]  # fmt: skip
        env = cls.finalize_environment(context)
        run(cmd + ["add", "--initdb"], sandbox=context.sandbox(network=True, options=options), env=env)
        run(
            cls.cmd(context, cached_metadata=False, allow_untrusted=context.config.repository_key_fetch)
            + ["update", *(["--update-cache"] if force else [])],
            sandbox=context.sandbox(network=True, options=options),
            env=env,
        )
        rmtree(tmp)

    @classmethod
    def fetch(cls, context: Context, packages: Sequence[str], output: Path) -> None:
        """Download apk package files for the given packages to output without installing them."""
        output.mkdir(parents=True, exist_ok=True)
        # apk fetch needs an initialized apk database, so use a throwaway root like sync() does.
        tmp = context.workspace / "apk-fetch-root"
        tmp.mkdir(exist_ok=True)
        cmd = cls.cmd(context, allow_untrusted=True)
        options = [
            "--bind", tmp, "/buildroot",
            "--bind", output, workdir(output),
            *cls.mounts(context),
            *cls.options(root=tmp, apivfs=False),
        ]  # fmt: skip
        run(
            cmd + ["add", "--initdb"],
            sandbox=context.sandbox(network=True, options=options),
            env=cls.finalize_environment(context),
        )
        run(
            cmd + ["fetch", "-o", workdir(output), *packages],
            sandbox=context.sandbox(network=True, options=options),
            env=cls.finalize_environment(context),
        )
        rmtree(tmp)

    @classmethod
    def extract(cls, context: Context, srcs: Sequence[Path], dest: Path) -> None:
        """Extract the contents of the given apk files into dest."""
        if not srcs:
            return

        dest.mkdir(parents=True, exist_ok=True)
        run(
            [
                "apk", "extract",
                "--allow-untrusted",
                "--no-chown",
                "--destination", workdir(dest),
                *(workdir(src) for src in srcs),
            ],
            sandbox=context.sandbox(
                options=[
                    "--bind", dest, workdir(dest),
                    *flatten(("--ro-bind", os.fspath(src), workdir(src)) for src in srcs),
                ]
            ),
            env=cls.finalize_environment(context),
        )  # fmt: skip

    @classmethod
    def createrepo(cls, context: Context) -> None:
        packages = [p.name for p in context.repository.glob("*.apk")]
        if not packages:
            return

        # Move apk files to arch-specific directory
        arch = cls.architecture(context)
        arch_dir = context.repository / arch
        arch_dir.mkdir(exist_ok=True)
        for package in packages:
            (context.repository / package).rename(arch_dir / package)

        # Generate temporary signing key using openssl
        # This uses the same method as abuild-keygen, because this tool is not available on all distros
        key = "mkosi@local"
        priv = context.workspace / f"{key}.rsa"
        pub = context.workspace / f"{key}.rsa.pub"

        if not priv.exists():
            run(
                ["openssl", "genrsa", "-out", workdir(priv), "2048"],
                sandbox=context.sandbox(options=["--bind", priv.parent, workdir(priv.parent)]),
                env=cls.finalize_environment(context),
            )
            run(
                ["openssl", "rsa", "-in", workdir(priv), "-pubout", "-out", workdir(pub)],
                sandbox=context.sandbox(
                    options=[
                        "--bind", priv, workdir(priv),
                        "--bind", pub.parent, workdir(pub.parent),
                    ]
                ),
                env=cls.finalize_environment(context),
            )  # fmt: skip
            keys = context.sandbox_tree / "etc/apk/keys"
            keys.mkdir(parents=True, exist_ok=True)
            copyfile(pub, keys / pub.name)

        run(
            [
                "apk",
                "index",
                "-o", "APKINDEX.tar.gz",
                "--rewrite-arch", arch,
                # packages may be signed by another key that might not be available.
                "--allow-untrusted",
                *packages,
            ],
            sandbox=context.sandbox(
                options=[
                    "--bind", context.repository, workdir(context.repository),
                    "--chdir", workdir(arch_dir),
                ]
            ),
            env=cls.finalize_environment(context),
        )  # fmt: skip

        # Create and sign index signature file
        # Note: The index signing stuff below was largely inspired by what abuild-sign and abuild-tar tools
        # do on Alpine Linux. These tools are not always packages for other distros.
        index = arch_dir / "APKINDEX.tar.gz"
        sig = arch_dir / f".SIGN.RSA.{pub.name}"
        run(
            [
                "openssl",
                "dgst",
                "-sha1",
                "-sign", workdir(priv),
                "-out", workdir(sig),
                workdir(index),
            ],
            sandbox=context.sandbox(
                options=[
                    "--bind", priv, workdir(priv),
                    "--bind", sig.parent, workdir(sig.parent),
                    "--bind", index, workdir(index),
                ]
            ),
            env=cls.finalize_environment(context),
        )  # fmt: skip

        tar = context.workspace / "sig.tar"
        with tar.open("wb") as f:
            run(
                [
                    "tar", "-cf", "-",
                    "--format=posix",
                    "--owner=0",
                    "--group=0",
                    "--numeric-owner",
                    "-C", workdir(arch_dir),
                    sig.name,
                ],
                sandbox=context.sandbox(options=["--bind", arch_dir, workdir(arch_dir)]),
                stdout=f,
            )  # fmt: skip

        # Strip EOF markers to allow concatenation with compressed index.
        data = tar.read_bytes()
        while data.endswith(b"\x00" * 512):
            data = data[:-512]
        tar.write_bytes(data)

        # Prepend gzipped signature to original index

        signed = context.workspace / "signed.tar.gz"
        with signed.open("wb") as out:
            run(
                ["gzip", "-n", "-9", "-c", workdir(tar)],
                sandbox=context.sandbox(options=["--bind", tar, workdir(tar)]),
                stdout=out,
            )
            out.write(index.read_bytes())

        # Finally, overwrite the original index archive with the signed index archive
        signed.rename(index)

        repos = context.sandbox_tree / "etc/apk/repositories"
        repo = "file:///repository/"
        if repos.exists():
            content = repos.read_text()
            if repo not in content:
                with repos.open("a") as f:
                    f.write(f"{repo}\n")
        else:
            repos.write_text(f"{repo}\n")

        cls.sync(context, force=True)
