# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
# SPDX-License-Identifier: GPL-2.0-or-later
"""Common definitions for the `debsigs` functional test suite."""

from __future__ import annotations

import dataclasses
import pathlib  # noqa: TCH003  # typedload needs this
import shutil
import subprocess  # noqa: S404
import typing


if typing.TYPE_CHECKING:
    from typing import Final, TypeVar

    TChangesFile = TypeVar("TChangesFile", bound="ChangesFile")


@dataclasses.dataclass
class Error(Exception):
    """The base class for errors that occurred during the debsigs test."""


@dataclasses.dataclass(frozen=True)
class ConfigPaths:
    """The paths to the temporary directories to run the test in."""

    base: pathlib.Path
    """The base directory, the temporary directory created for the whole test."""

    home: pathlib.Path
    """The directory to serve as the current account's home directory."""

    work: pathlib.Path
    """The directory to run the test in."""


@dataclasses.dataclass(frozen=True)
class ConfigPrograms:
    """The paths to the programs to test and test with."""

    debsigs: pathlib.Path
    """The path to the `debsigs` implementation to test."""

    debsig_verify: pathlib.Path
    """The path to the `debsig-verify` program to test the signed files with."""

    gnupg: pathlib.Path
    """The path to the `gpg` program to use and test with."""

    sop: pathlib.Path
    """The path to the stateless OpenPGP program to create keys and possibly sign with."""


@dataclasses.dataclass(frozen=True)
class Config:
    """Runtime configuration for the test suite."""

    env: dict[str, str]
    """The environment variables to run child processes with."""

    path: ConfigPaths
    """The paths to the temporary directories to run the test in."""

    prog: ConfigPrograms
    """The paths to the programs to test and test with."""


@dataclasses.dataclass(frozen=True)
class KeyFiles:
    """The paths to the armored and dearmored OpenPGP key files to test with."""

    secret: pathlib.Path
    """The ASCII-armored secret key file."""

    secret_bin: pathlib.Path
    """The dearmored secret key file."""

    public: pathlib.Path
    """The ASCII-armored public key file."""

    public_bin: pathlib.Path
    """The dearmored public key file."""


@dataclasses.dataclass(frozen=True)
class Subkey:
    """The parts of an OpenPGP subkey that we care about."""

    key_id: str
    """The hex string identifier of the subkey."""

    fpr: str
    """The full hex fingerprint of the key."""

    capabilities: set[str]
    """The capabilities of this subkey (e.g. `s` for sign, `e` for encrypt, etc.)."""


@dataclasses.dataclass(frozen=True)
class PublicKey:
    """The parts of an OpenPGP public key that we care about."""

    key_id: str
    """The hex string identifier of the public key."""

    fpr: str
    """The full hex fingerprint of the key."""

    uid: str
    """The user ID associated with this key."""

    subkeys: list[Subkey]
    """The subkeys defined for this key (there must be at least one)."""


@dataclasses.dataclass(frozen=True)
class ChangesFile:
    """List the files described in a Debian archive `*.changes` file."""

    path: pathlib.Path
    """The path to the changes file itself."""

    debs: list[pathlib.Path]
    """The Debian packages included in the changes file."""

    others: list[pathlib.Path]
    """Any other files included in the changes file."""

    @classmethod
    def craft(
        cls: type[TChangesFile],
        cfg: Config,
        path: pathlib.Path,
        debs: list[pathlib.Path],
        others: list[pathlib.Path],
    ) -> TChangesFile:
        """Create something resembling a `*.changes` file."""
        if any(item.parent != path.parent or item == path for item in debs):
            raise RuntimeError(repr((path, debs)))
        if any(item.parent != path.parent or item == path for item in others):
            raise RuntimeError(repr((path, others)))
        if set(debs) & set(others):
            raise RuntimeError(repr((debs, others)))

        binaries: Final = [deb.name.split("_")[0] for deb in debs]
        all_files: Final = [*debs, *others]
        contents = f"""Format: 1.8
Date: Wed, 29 Dec 2021 16:46:40 +0200
Source: debsigs-test
Binary: {" ".join(binaries)}
Architecture: source all
Version: 0.1.0
Distribution: unstable
Urgency: medium
Maintainer: Peter Pentchev <roam@debian.org>
Changed-By: Peter Pentchev <roam@debian.org>
Description:
 debsigs-test    - test the operation of the debsigs suite
Changes:
 debsigs-test (0.1.0) unstable; urgency=medium
 .
   * Nothing really.
"""

        for heading in ("Sha1", "Sha256"):
            contents += f"Checksums-{heading}:\n"
            for filepath in all_files:
                checksum = subprocess.check_output(  # noqa: S603
                    [
                        f"{heading.lower()}sum",
                        "--",
                        filepath,
                    ],
                    encoding="UTF-8",
                    env=cfg.env,
                ).split()[0]
                filesize = filepath.stat().st_size
                contents += f" {checksum} {filesize} {filepath.name}\n"

        contents += "Files:\n"
        for filepath in all_files:
            checksum = subprocess.check_output(  # noqa: S603
                [  # noqa: S607
                    "md5sum",
                    "--",
                    filepath,
                ],
                cwd=cfg.path.work,
                encoding="UTF-8",
                env=cfg.env,
            ).split()[0]
            filesize = filepath.stat().st_size
            contents += f" {checksum} {filesize} devel optional {filepath.name}\n"

        path.write_text(contents, encoding="UTF-8")
        return cls(path=path, debs=debs, others=others)

    def verify(self, cfg: Config) -> None:
        """Run `dscverify` without verifying the nonexistent OpenPGP signature."""
        lines: Final = subprocess.check_output(  # noqa: S603
            ["dscverify", "-u", "--", self.path],  # noqa: S607
            cwd=cfg.path.work,
            encoding="UTF-8",
            env=cfg.env,
        ).splitlines()
        verified: Final = {
            part[2] for part in (line.partition("validating ") for line in lines) if part[1]
        }
        if verified != {deb.name for deb in self.debs} | {other.name for other in self.others}:
            raise RuntimeError(repr((self, lines)))

    def copy_to(self: TChangesFile, destdir: pathlib.Path) -> TChangesFile:
        """Copy the changes file and all the files it references over to another location."""
        npath: Final = destdir / self.path.name
        shutil.copy2(self.path, npath)

        ndebs: Final = []
        for deb in self.debs:
            ndeb = destdir / deb.name
            shutil.copy2(deb, ndeb)
            ndebs.append(ndeb)

        nothers: Final = []
        for other in self.others:
            nother = destdir / other.name
            shutil.copy2(other, nother)
            nothers.append(nother)

        return type(self)(path=npath, debs=ndebs, others=nothers)

    def get_different_files(self, cfg: Config, other: ChangesFile) -> list[str]:
        """Get the filenames of the files that differ between this changes file and another one."""
        ours: Final = {path.name: path for path in [*self.debs, *self.others]}
        theirs: Final = {path.name: path for path in [*other.debs, *other.others]}
        our_names: Final = set(ours)
        their_names: Final = set(theirs)
        return sorted(
            our_names.symmetric_difference(their_names)
            | {
                name
                for name in (our_names & their_names)
                if subprocess.run(  # noqa: S603
                    ["cmp", "--", ours[name], theirs[name]],  # noqa: S607
                    check=False,
                    cwd=cfg.path.work,
                    env=cfg.env,
                ).returncode
                != 0
            },
        )


@dataclasses.dataclass(frozen=True)
class DebSig:
    """A single signature on a package."""

    sig_type: str
    """The type of the signature, e.g. "origin", "maint", etc."""

    key_id: str
    """The hex identifier of the OpenPGP key that the signature was made with."""
