"""Run some tests on the makedeltarpm program."""

from __future__ import annotations

import dataclasses
import functools
import pathlib
import shutil
import struct
import subprocess  # noqa: S404
import sys
import tempfile
import typing

import click
import jinja2


if typing.TYPE_CHECKING:
    from typing import Final


VERSION: Final = "0.1.1"

NUMBERS_ON_A_LINE: Final = 100


@dataclasses.dataclass(frozen=True, kw_only=True)
class Config:
    """Runtime configuration for the makedeltarpm test tool."""

    bindir: pathlib.Path
    datadir: pathlib.Path
    savedir: pathlib.Path | None
    verbose: bool

    def diag(self, msg: str) -> None:
        """Output a diagnostic message if requested."""
        if self.verbose:
            print(msg, file=sys.stderr)


@functools.lru_cache
def textfile(textdir: pathlib.Path, idx: int) -> pathlib.Path:
    """Return the path to the plaintext test file."""
    return textdir / f"{idx:02}.txt"


@functools.lru_cache
def binfile(bindir: pathlib.Path, idx: int) -> pathlib.Path:
    """Return the path to the binary file."""
    return bindir / f"{idx:02}.bin"


@functools.lru_cache
def conffile(confdir: pathlib.Path, idx: int) -> pathlib.Path:
    """Return the path to the config file."""
    return confdir / f"{idx:02}.conf"


def create_source_textfile(textdir: pathlib.Path, idx: int) -> None:
    """Create a file to be packaged up in the test RPM packages."""
    textfile(textdir, idx).write_text(
        "".join(f"{num:02x}" for num in range(idx, idx + NUMBERS_ON_A_LINE)) + "\n",
        encoding="us-ascii",
    )


def modify_source_textfile(cfg: Config, textdir: pathlib.Path, idx: int) -> None:
    """Create some differences between the RPM packages."""
    tfile: Final = textfile(textdir, idx)
    cfg.diag(f"- modifying {tfile}")
    lines: Final = tfile.read_text(encoding="us-ascii").splitlines()
    match lines:
        case [single] if "0" <= single[0] <= "8":
            tfile.write_text(
                chr(ord(single[0]) + 1) + single[1:] + "\n",
                encoding="us-ascii",
            )

        case _:
            raise RuntimeError(repr((tfile, lines)))


def create_source_binfile(bindir: pathlib.Path, textdir: pathlib.Path, idx: int) -> None:
    """Maybe create a binary file to be packaged up in the test RPM packages."""
    tfile: Final = textfile(textdir, idx)
    if not tfile.exists():
        return

    lines: Final = tfile.read_text(encoding="us-ascii").splitlines()
    match lines:
        case [single] if len(single) == 2 * NUMBERS_ON_A_LINE:
            binfile(bindir, idx).write_bytes(
                struct.pack(
                    f"{len(single) // 2}B",
                    *[int(single[2 * sub : 2 * sub + 2], 16) for sub in range(len(single) // 2)],
                ),
            )

        case _:
            raise RuntimeError(repr((tfile, lines)))


def create_source_conffile(confdir: pathlib.Path, textdir: pathlib.Path, idx: int) -> None:
    """Maybe create a config file to be packaged up in the test RPM package."""
    tfile: Final = textfile(textdir, idx)
    if not tfile.exists():
        return

    conffile(confdir, idx).write_text(
        tfile.read_text(encoding="us-ascii"),
        encoding="us-ascii",
    )


def create_source_tree(cfg: Config, srcdir: pathlib.Path, *, later: bool) -> None:
    """Create the tree of files to pack up into a tarball."""
    srcdir.mkdir(mode=0o755)

    textdir: Final = srcdir / "usr/share/whee"
    textdir.mkdir(mode=0o755, parents=True)
    cfg.diag(f"Setting up files in the {textdir} directory")

    for idx in range(NUMBERS_ON_A_LINE):
        create_source_textfile(textdir, idx)

    for idx in (0, 33, 66) if later else (11, 44, 77):
        cfg.diag(f"- removing {textfile(textdir, idx)}")
        textfile(textdir, idx).unlink()

    if later:
        for idx in (22, 55, 88):
            modify_source_textfile(cfg, textdir, idx)

    bindir: Final = srcdir / "bin"
    bindir.mkdir(mode=0o755)
    cfg.diag(f"Setting up files in the {bindir} directory")

    for idx in range(NUMBERS_ON_A_LINE):
        create_source_binfile(bindir, textdir, idx)

    confdir: Final = srcdir / "etc"
    confdir.mkdir(mode=0o755)
    cfg.diag(f"Setting up files in the {confdir} directory")

    for idx in range(NUMBERS_ON_A_LINE):
        create_source_conffile(confdir, textdir, idx)


def prepare_testdir(
    cfg: Config,
    tempd: pathlib.Path,
) -> tuple[pathlib.Path, dict[str, pathlib.Path]]:
    """Prepare the test directory tree."""
    srcdir: Final = tempd / "source"
    srcdir.mkdir(mode=0o755)
    cfg.diag(f"Using {srcdir} as the base source directory")

    versions: Final = ["0.1.0", "0.1.1"]
    names: Final = ["whee-" + ver for ver in versions]

    create_source_tree(cfg, srcdir / names[0], later=False)
    create_source_tree(cfg, srcdir / names[1], later=True)

    archives: Final = [srcdir / (name + ".tar.gz") for name in names]
    subprocess.check_call(["tar", "-caf", archives[0], names[0]], cwd=srcdir)  # noqa: S603,S607
    subprocess.check_call(["tar", "-caf", archives[1], names[1]], cwd=srcdir)  # noqa: S603,S607

    return srcdir, dict(zip(versions, archives, strict=True))


def prepare_rpmdir(
    cfg: Config,
    srcdir: pathlib.Path,
    archives: dict[str, pathlib.Path],
) -> pathlib.Path:
    """Prepare the rpmbuild topdir."""
    topdir: Final = srcdir.parent / "rpmbuild"
    topdir.mkdir(mode=0o755)
    cfg.diag(f"Preparing the rpmbuild tree in {topdir}")

    (topdir / "SOURCES").mkdir(mode=0o755)
    for archive in archives.values():
        archive.rename(topdir / "SOURCES" / archive.name)

    return topdir


def build_rpms(
    cfg: Config,
    topdir: pathlib.Path,
    versions: list[str],
) -> dict[str, pathlib.Path]:
    """Build the RPM packages."""
    jenv: Final = jinja2.Environment(
        autoescape=False,  # noqa: S701  # we control the input data
        loader=jinja2.FileSystemLoader(cfg.datadir),
        undefined=jinja2.StrictUndefined,
    )

    def generate(ver: str) -> pathlib.Path:
        """Generate a single specfile."""
        specfile: Final = topdir.parent / f"whee-{ver}.spec"
        cfg.diag(f"Rendering the template for {ver}")
        result = jenv.get_template("whee.spec.j2").render(version=ver)
        cfg.diag(f"Generating {specfile}")
        specfile.write_text(result, encoding="UTF-8")
        return specfile

    specfiles: Final = {ver: generate(ver) for ver in versions}
    rpmsdir: Final = topdir / "RPMS"
    resultdir: Final = topdir.parent / "result"
    resultdir.mkdir(mode=0o755)

    def build(ver: str, specfile: pathlib.Path) -> pathlib.Path:
        """Build a single RPM package."""
        if rpmsdir.exists():
            cfg.diag(f"Cleaning up {rpmsdir}")
            shutil.rmtree(rpmsdir)
        cfg.diag(f"Building an RPM package from {specfile}")
        subprocess.check_call(  # noqa: S603
            [  # noqa: S607
                "rpmbuild",
                "-ba",
                "-D_rpmformat 4",
                f"-D_topdir {topdir}",
                "--",
                specfile,
            ],
        )

        if not rpmsdir.is_dir():
            sys.exit(f"The RPM build did not create {rpmsdir}")
        expected: Final = rpmsdir / f"noarch/whee-{ver}-1.noarch.rpm"
        if not expected.is_file():
            sys.exit(
                f"The RPM build did not create {expected}, got this instead\n"
                + "\n".join(sorted(str(path) for path in rpmsdir.rglob("*"))),
            )
        final: Final = resultdir / expected.name
        expected.rename(final)
        return final

    return {ver: build(ver, specfiles[ver]) for ver in versions}


def make_delta(
    cfg: Config,
    rpmfiles: dict[str, pathlib.Path],
) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]:
    """Create a delta rpm, verify it contains what it is supposed to."""
    # Yeah, yeah, maybe we should use trivver here, too...
    versions: Final = sorted(rpmfiles.keys())
    oldrpm: Final = rpmfiles[versions[0]]
    newrpm: Final = rpmfiles[versions[1]]
    deltarpm: Final = oldrpm.parent / "delta.rpm"
    cfg.diag(f"Creating {deltarpm} from {oldrpm} and {newrpm}")
    subprocess.check_call([cfg.bindir / "makedeltarpm", "--", oldrpm, newrpm, deltarpm])  # noqa: S603
    if not deltarpm.is_file():
        sys.exit(f"makedeltarpm did not create {deltarpm}")
    return oldrpm, newrpm, deltarpm


def reconstruct(
    cfg: Config,
    oldrpm: pathlib.Path,
    deltarpm: pathlib.Path,
) -> pathlib.Path:
    """Reconstruct an RPM package from the delta."""
    reconstructed: Final = oldrpm.parent / "reconstructed.rpm"
    cfg.diag(f"Reconstructing {reconstructed} from {oldrpm} and {deltarpm}")
    subprocess.check_call(  # noqa: S603
        [
            cfg.bindir / "applydeltarpm",
            "-r",
            oldrpm,
            "--",
            deltarpm,
            reconstructed,
        ],
    )
    if not reconstructed.is_file():
        sys.exit(f"applydeltarpm did not create {reconstructed}")
    return reconstructed


@click.command(name="test-drpm")
@click.option(
    "-b",
    "--bindir",
    type=click.Path(
        file_okay=False,
        dir_okay=True,
        exists=True,
        path_type=pathlib.Path,
        resolve_path=True,
    ),
    required=True,
    help="the path to the directory containing the makedeltarpm program",
)
@click.option(
    "-d",
    "--datadir",
    type=click.Path(
        file_okay=False,
        dir_okay=True,
        exists=True,
        path_type=pathlib.Path,
        resolve_path=True,
    ),
    required=True,
    help="the path to the directory containing the spec template",
)
@click.option(
    "-S",
    "--savedir",
    type=click.Path(
        file_okay=False,
        dir_okay=True,
        exists=True,
        path_type=pathlib.Path,
        resolve_path=True,
    ),
    help="the path to the directory to save the generated RPM files",
)
@click.option(
    "-v",
    "--verbose",
    is_flag=True,
    help="verbose operation; display diagnostic output",
)
def main(
    *,
    bindir: pathlib.Path,
    datadir: pathlib.Path,
    savedir: pathlib.Path,
    verbose: bool,
) -> None:
    """Parse command-line options, run tests."""
    if not (bindir / "makedeltarpm").is_file() or not (bindir / "applydeltarpm").is_file():
        sys.exit(f"No {bindir}/makedeltarpm or {bindir}/applydeltarpm")
    if not (datadir / "whee.spec.j2").is_file():
        sys.exit(f"No {datadir}/whee.spec.j2")

    cfg: Final = Config(
        bindir=bindir,
        datadir=datadir,
        savedir=savedir,
        verbose=verbose,
    )

    cfg.diag(f"Testing the deltarpm programs in {cfg.bindir}")
    with tempfile.TemporaryDirectory() as tempd_obj:
        tempd: Final = pathlib.Path(tempd_obj)
        srcdir, archives = prepare_testdir(cfg, tempd)
        versions: Final = sorted(archives.keys())
        print(f"Got source archives {archives!r}")
        topdir: Final = prepare_rpmdir(cfg, srcdir, archives)
        print(f"Got RPM build tree {sorted(topdir.rglob('*'))}")
        rpmfiles: Final = build_rpms(cfg, topdir, versions)
        print(f"Got RPM files {rpmfiles!r}")
        oldrpm, newrpm, deltarpm = make_delta(cfg, rpmfiles)
        print(f"Got delta {deltarpm} to go from {oldrpm} to {newrpm}")
        reconstructed: Final = reconstruct(cfg, oldrpm, deltarpm)
        print(f"Got reconstructed package {reconstructed}")
        subprocess.check_call(["cmp", "--", newrpm, reconstructed])  # noqa: S603,S607
        print("They are the same!")

        if cfg.savedir:
            print(f"Copying the RPM files to {cfg.savedir}")
            for path in oldrpm, newrpm, deltarpm, reconstructed:
                cfg.diag(f"- {path.name}")
                shutil.copy(path, cfg.savedir / path.name)


if __name__ == "__main__":
    main()
