# Copyright (c) 2021  Peter Pentchev <roam@ringlet.net>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#
"""Run some tests on the makedeltarpm program."""

import argparse
import dataclasses
import pathlib
import shutil
import struct
import subprocess
import sys
import tempfile

from typing import Dict, List, Optional, Tuple  # noqa: H301

import jinja2


VERSION = "0.1.0"


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

    bindir: pathlib.Path
    datadir: pathlib.Path
    savedir: Optional[pathlib.Path]
    verbose: bool

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


def parse_args() -> Config:
    """Parse the command-line options."""
    parser = argparse.ArgumentParser(prog="test_drpm")
    parser.add_argument(
        "-b",
        "--bindir",
        type=pathlib.Path,
        required=True,
        help="the path to the directory containing the makedeltarpm program",
    )
    parser.add_argument(
        "-d",
        "--datadir",
        type=pathlib.Path,
        required=True,
        help="the path to the directory containing the spec template",
    )
    parser.add_argument(
        "-S",
        "--savedir",
        type=pathlib.Path,
        help="the path to the directory to save the generated RPM files",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="verbose operation; display diagnostic output",
    )

    args = parser.parse_args()

    bindir = args.bindir
    if (
        not (bindir / "makedeltarpm").is_file()
        or not (bindir / "applydeltarpm").is_file()
    ):
        sys.exit(f"No {bindir}/makedeltarpm or {bindir}/applydeltarpm")
    datadir = args.datadir
    if not (datadir / "whee.spec.j2").is_file():
        sys.exit(f"No {datadir}/whee.spec.j2")

    return Config(
        bindir=bindir, datadir=datadir, savedir=args.savedir, verbose=args.verbose
    )


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 = srcdir / "usr/share/whee"
    textdir.mkdir(mode=0o755, parents=True)
    cfg.diag(f"Setting up files in the {textdir} directory")

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

    for idx in range(100):
        textfile(idx).write_text(
            "".join(f"{num:02x}" for num in range(idx, idx + 100)) + "\n",
            encoding="us-ascii",
        )

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

    if later:
        for idx in (22, 55, 88):
            cfg.diag(f"- modifying {textfile(idx)}")
            lines = textfile(idx).read_text(encoding="us-ascii").splitlines()
            assert len(lines) == 1, repr((textfile(idx), lines))
            assert "0" <= lines[0][0] <= "8", repr((textfile(idx), lines))
            textfile(idx).write_text(
                chr(ord(lines[0][0]) + 1) + lines[0][1:] + "\n", encoding="us-ascii"
            )

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

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

    for idx in range(100):
        tfile = textfile(idx)
        if tfile.exists():
            lines = tfile.read_text(encoding="us-ascii").splitlines()
            assert len(lines) == 1, repr((tfile, lines))
            assert len(lines[0]) == 200, repr((tfile, lines))
            binfile(idx).write_bytes(
                struct.pack(
                    f"{len(lines[0]) // 2}B",
                    *[
                        int(lines[0][2 * sub : 2 * sub + 2], 16)
                        for sub in range(len(lines[0]) // 2)
                    ],
                )
            )

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

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

    for idx in range(100):
        tfile = textfile(idx)
        if tfile.exists():
            conffile(idx).write_text(
                tfile.read_text(encoding="us-ascii"), encoding="us-ascii"
            )


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

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

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

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

    return srcdir, dict(zip(versions, archives))


def prepare_rpmdir(
    cfg: Config, srcdir: pathlib.Path, archives: Dict[str, pathlib.Path]
) -> pathlib.Path:
    """Prepare the rpmbuild topdir."""
    topdir = 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 = jinja2.Environment(
        autoescape=False,
        loader=jinja2.FileSystemLoader(cfg.datadir),
        undefined=jinja2.StrictUndefined,
    )

    def generate(ver: str) -> pathlib.Path:
        """Generate a single specfile."""
        specfile = 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 = {ver: generate(ver) for ver in versions}
    rpmsdir = topdir / "RPMS"
    resultdir = 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(
            ["rpmbuild", "-ba", f"-D_topdir {topdir}", "--", specfile]
        )

        if not rpmsdir.is_dir():
            sys.exit(f"The RPM build did not create {rpmsdir}")
        expected = 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 = 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 = sorted(rpmfiles.keys())
    oldrpm = rpmfiles[versions[0]]
    newrpm = rpmfiles[versions[1]]
    deltarpm = oldrpm.parent / "delta.rpm"
    cfg.diag(f"Creating {deltarpm} from {oldrpm} and {newrpm}")
    subprocess.check_call([cfg.bindir / "makedeltarpm", "--", oldrpm, newrpm, deltarpm])
    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 = oldrpm.parent / "reconstructed.rpm"
    cfg.diag(f"Reconstructing {reconstructed} from {oldrpm} and {deltarpm}")
    subprocess.check_call(
        [cfg.bindir / "applydeltarpm", "-r", oldrpm, "--", deltarpm, reconstructed]
    )
    if not reconstructed.is_file():
        sys.exit(f"applydeltarpm did not create {reconstructed}")
    return reconstructed


def main() -> None:
    """Main program: parse command-line options, run tests."""
    cfg = parse_args()
    cfg.diag(f"Testing the deltarpm programs in {cfg.bindir}")
    with tempfile.TemporaryDirectory() as tempd_obj:
        tempd = pathlib.Path(tempd_obj)
        srcdir, archives = prepare_testdir(cfg, tempd)
        versions = sorted(archives.keys())
        print(f"Got source archives {archives!r}")
        topdir = prepare_rpmdir(cfg, srcdir, archives)
        print(f"Got RPM build tree {sorted(topdir.rglob('*'))}")
        rpmfiles = 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 = reconstruct(cfg, oldrpm, deltarpm)
        print(f"Got reconstructed package {reconstructed}")
        subprocess.check_call(["cmp", "--", newrpm, reconstructed])
        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()
