"debputy: test generation of the (static-)built-using field."

import dataclasses
import textwrap
import typing
from dataclasses import dataclass
from io import StringIO

import pytest

from conftest import ManifestParserFactory
from debputy.filesystem_scan import FSRootDir
from debputy.plugin.api import virtual_path_def
from debputy.plugin.api.impl_types import (
    PackageProcessingContextProvider,
    PackageDataTable,
)
from debputy.plugin.api.test_api import build_virtual_file_system
from debputy.plugin.api.test_api.test_impl import initialize_plugin_under_test_preloaded
from debputy.plugins.debputy.debputy_plugin import initialize_debputy_features
from debputy.util import PKGNAME_REGEX
from debputy.yaml import MANIFEST_YAML
from tutil import is_pkg_installed


@dataclass(slots=True)
class BinPkg:
    "Fake binary package built by a Test."

    name: str
    binary_paragraph: str
    manifest: str
    expected_bu: set[str] | None = dataclasses.field(
        default_factory=set
    )  # None means not built
    expected_sbu: set[str] | None = dataclasses.field(default_factory=set)


@dataclass(slots=True)
class TDefinition:
    "Fake source package used by a test."

    name: str
    source_paragraph: str
    required_pkgs: list[str]
    packages: typing.Sequence[BinPkg]


# More or less in sync with dh-builtusing unit-tests.
tests = (
    TDefinition(
        name="01basic",
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
  autotools-dev <dummy>, gcc [!amd64], libc6, make
Build-Depends-Arch: libdpkg-perl
Build-Depends-Indep: libbinutils""",
        required_pkgs=["autotools-dev", "gcc", "libc6"],
        packages=(
            BinPkg(
                name="foo",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: autotools-dev  # disabled by profile restriction
    - sources-for: gcc            # disabled by architecture restriction
    - sources-for: 'lib*'         # match in BD and BD-Indep, not BD-Arch
    static-built-using:           # also check static-built-using
    - sources-for: make""",
                expected_bu={"binutils", "glibc"},
                expected_sbu={"make-dfsg"},
            ),
            BinPkg(
                name="bar",
                binary_paragraph="Architecture: any",
                manifest="""\
    built-using:
    - sources-for: 'lib*'         # match in BD and BD-Arch, not BD-Indep""",
                expected_bu={"dpkg", "glibc"},
            ),
            BinPkg(
                name="package-disabled-by-arch",
                binary_paragraph="Architecture: i386",
                manifest="""\
    built-using:
    - sources-for: libc6          # package disabled by architecture""",
                expected_bu=None,
                expected_sbu=None,
            ),
            BinPkg(
                name="package-disabled-by-profile",
                binary_paragraph="""\
Architecture: all
Build-Profiles: <dummy>""",
                manifest="""\
    built-using:
    - sources-for: make           # package disabled by build profile""",
                expected_bu=None,
                expected_sbu=None,
            ),
        ),
    ),
    TDefinition(
        name="30or-dependency",
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
  cpp | dummy1, dummy2 | binutils""",
        required_pkgs=["cpp", "binutils"],
        packages=(
            BinPkg(
                name="foo",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: cpp
    - sources-for: binutils""",
                expected_bu={"gcc-defaults", "binutils"},
            ),
        ),
    ),
    TDefinition(
        name="40pattern",
        required_pkgs=["gcc", "g++"],
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~), gcc, g++
Build-Depends-Arch: libbinutils
Build-Depends-Indep: libc6""",
        packages=(
            BinPkg(
                name="initial",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: '*c'""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="final",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: 'gc*'""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="empty",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: 'g*cc'""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="one-char",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: 'g*c'""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="encoding-star-plus",  # + is common re character
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: 'g*+'""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="encoding-plus",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: g++            # match despite regex characters""",
                expected_bu={"gcc-defaults"},
            ),
            BinPkg(
                name="ambiguous-all",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: 'lib*'""",
                expected_bu={"glibc"},
            ),
            BinPkg(
                name="ambiguous-any",
                binary_paragraph="Architecture: any",
                manifest="""\
    built-using:
    - sources-for: 'lib*'""",
                expected_bu={"binutils"},
            ),
        ),
    ),
    TDefinition(
        name="50same-source",
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
  autotools-dev, cpp, gcc, libc6, make""",
        required_pkgs=["dpkg-dev", "autotools-dev", "cpp", "gcc"],
        packages=(
            BinPkg(
                name="foo",
                binary_paragraph="Architecture: any",
                manifest="""\
    built-using:
    - sources-for: '*-dev'        # multiple matches
    - sources-for: cpp
    - sources-for: gcc            # same source than cpp
    - sources-for: libc6          # architecture manifest condition
      when:
        arch-matches: '!amd64'
    - sources-for: make           # profile manifest condition
      when:
        build-profiles-matches: <dummyprofile>""",
                expected_bu={"autotools-dev", "dpkg", "gcc-defaults"},
            ),
        ),
    ),
    TDefinition(
        name="70arch-suffix",
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
  debhelper:all, gcc:amd64, libc6:amd64""",
        required_pkgs=["debhelper", "gcc", "libc6"],
        packages=(
            BinPkg(
                name="foo",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: debhelper      # all
    - sources-for: gcc            # native
    - sources-for: libc6          # same""",
                expected_bu={"debhelper", "gcc-defaults", "glibc"},
            ),
        ),
    ),
    TDefinition(
        name="70multiarch",
        source_paragraph="""\
Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
  gcc, libc6, make""",
        required_pkgs=["gcc", "libc6", "dpkg-dev", "make"],
        packages=(
            BinPkg(
                name="foo",
                binary_paragraph="Architecture: all",
                manifest="""\
    built-using:
    - sources-for: gcc            # no
    - sources-for: libc6          # same
    - sources-for: dpkg-dev       # foreign
    - sources-for: make           # allowed""",
                expected_bu={"gcc-defaults", "glibc", "dpkg", "make-dfsg"},
            ),
        ),
    ),
)


def _pkg_names_from_substvar(resolve_substvar: str) -> set[str]:
    parts = (p.strip() for p in resolve_substvar.split(","))
    result = set()
    for p in parts:
        result.add(PKGNAME_REGEX.match(p).group(0))
    return result


@pytest.mark.parametrize("test_definition", tests, ids=[t.name for t in tests])
def test_built_using(
    manifest_parser_amd64_factory: ManifestParserFactory,
    test_definition: TDefinition,
) -> None:
    if not all(is_pkg_installed(p) for p in test_definition.required_pkgs):
        pytest.skip(
            f"The test requires the following packages to be installed: {test_definition.required_pkgs}"
        )
        return

    plugin = initialize_plugin_under_test_preloaded(
        1,
        initialize_debputy_features,
        plugin_name="debputy",
        load_debputy_plugin=False,
    )
    metadata_detector_id = "built-using-relations"
    dctrl_parts = [
        textwrap.dedent(
            """\
            Source: foo
            Section: misc
            Priority: optional
            Maintainer: Test <testing@nowhe>
            Standards-Version: 4.7.2
            Build-Driver: debputy
            {test.source_paragraph}
        """
        ).format(test=test_definition)
    ]
    dctrl_parts.extend(
        textwrap.dedent(
            """\
            Package: {p.name}
            Description: short
             Long.
            {p.binary_paragraph}
        """
        ).format(p=p)
        for p in test_definition.packages
    )
    manifest_data = {
        "manifest-version": "0.1",
    }
    if any(p.manifest for p in test_definition.packages):
        pkgs = {}
        for p in test_definition.packages:
            snippet = MANIFEST_YAML.load(p.manifest)
            pkgs[p.name] = snippet
        manifest_data["packages"] = pkgs

    dctrl_contents = "\n".join(dctrl_parts)
    # A bit of debugging output in case the test fails
    print(" --- d/control ---")
    print(dctrl_contents)
    print(" --- d/debputy.manifest (data) ---")
    print(manifest_data)
    fd = StringIO()
    MANIFEST_YAML.dump(manifest_data, fd)
    manifest_contents = fd.getvalue()
    print(" --- d/debputy.manifest ---")
    print(manifest_contents)

    fs = build_virtual_file_system(
        [
            virtual_path_def("./control", content=dctrl_contents),
        ]
    )
    manifest = manifest_parser_amd64_factory(fs).parse_manifest(fd=manifest_contents)

    # TODO: Technically we should skip `p` when `expected_bu` / `expected_sbu` are `None`, since
    #  debputy would never call the metadata detector in this case.
    for p in test_definition.packages:
        binary_package = manifest.package_state_for(p.name).binary_package

        # Test data should be consistent
        assert (p.expected_bu is None) == (p.expected_sbu is None)

        if p.expected_bu is None:
            assert not binary_package.should_be_acted_on
            continue

        assert binary_package.should_be_acted_on

        metadata = plugin.run_metadata_detector(
            metadata_detector_id,
            FSRootDir(),
            PackageProcessingContextProvider(
                manifest,
                binary_package,
                None,
                PackageDataTable({}),
            ),
        )
        for expected, field in (
            (p.expected_bu, "Built-Using"),
            (p.expected_sbu, "Static-Built-Using"),
        ):
            # Present but empty set implies we run the binary but
            # not produce a result.
            if expected:
                actual = _pkg_names_from_substvar(
                    metadata.substvars[f"debputy:{field}"]
                )
                assert actual == expected
            else:
                assert f"debputy:{field}" not in metadata.substvars
