# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
# SPDX-License-Identifier: BSD-2-Clause
"""A test framework for the repopush tool."""

from __future__ import annotations

import argparse
import dataclasses
import enum
import json
import os
import pathlib
import subprocess  # noqa: S404
import sys
import tempfile
import typing

from . import util


if typing.TYPE_CHECKING:
    from typing import Final


BAD_ARGS: Final[list[util.PathList]] = [
    [],
    ["-N"],
    ["-v"],
    ["-N", "-v"],
]

PAIRS_BAD: Final = [
    ("empty", "empty"),
    ("empty", "deb/simple"),
    ("empty", "yum/simple"),
    ("empty", "yum/march"),
]

PAIRS_HALF_BAD: Final = [
    ("yum/simple", "empty"),
    ("yum/simple", "deb/simple"),
    ("yum/simple", "yum/march"),
    ("yum/march", "empty"),
    ("yum/march", "deb/simple"),
    ("yum/march", "yum/simple"),
]

PAIRS_GOOD: Final = [
    ("deb/simple", "empty"),
    ("deb/simple", "deb/simple"),
    ("deb/simple", "yum/simple"),
    ("deb/simple", "yum/march"),
    ("yum/simple", "yum/simple"),
    ("yum/march", "yum/march"),
]

PAIRS_COUNT_EXPECTED: Final = 16


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

    program: pathlib.Path
    test_data: pathlib.Path


class TestError(Exception):
    """An error that occurred during the test."""

    cmd: util.PathList
    cmd_str: str
    failure: str

    def __init__(self, cmd: util.PathList, failure: str) -> None:
        """Store the failure data."""
        self.cmd = cmd
        self.cmd_str = util.cmdstr(cmd)
        self.failure = failure
        super().__init__(f"Unexpected result from `{self.cmd_str}`: {failure}")


class ConfigFileMode(enum.StrEnum):
    """Whether 'tis better to create a config file or not..."""

    NONE = "none"
    FILE = "file"
    HOME = "home"


@dataclasses.dataclass(frozen=True)
class TestCase:
    """Definition of a single test case."""

    config: ConfigFileMode
    env: dict[str, str]
    src_slug: str
    dst_slug: str
    noop: bool


def parse_args() -> Config:
    """Parse the command-line arguments."""
    parser: Final = argparse.ArgumentParser(prog="repopush_test")
    parser.add_argument(
        "-p",
        "--program",
        type=pathlib.Path,
        default=os.getenv("REPOPUSH", "repopush.sh"),
        help="the path to the repopush program to test",
    )
    parser.add_argument(
        "-t",
        "--test-data",
        type=pathlib.Path,
        default=os.getenv("REPOPUSH_TEST_DATA", "test_data"),
        help="the path to the test data directory",
    )

    args: Final = parser.parse_args()

    program: Final[pathlib.Path] = args.program.absolute()
    if not program.is_file() or not os.access(program, os.X_OK):
        sys.exit(f"Not an executable file: {program}")

    test_data: Final[pathlib.Path] = args.test_data.absolute()
    if not test_data.is_dir() or not os.access(test_data, os.R_OK | os.X_OK):
        sys.exit(f"Not a readable directory: {test_data}")

    return Config(program=program, test_data=test_data)


def test_bad_args(cfg: Config, tempd: pathlib.Path) -> None:
    """Test that repopush will fail with no or bad arguments."""
    bad_args: Final = (
        BAD_ARGS
        + [[*item, tempd] for item in BAD_ARGS]
        + [[*item, tempd, tempd, tempd] for item in BAD_ARGS]
    )
    for args in bad_args:
        cmd: util.PathList = [cfg.program]
        cmd.extend(args)
        print(f"Expect a failure from `{util.cmdstr(cmd)}`\n")
        res = subprocess.run(cmd, bufsize=0, capture_output=True, check=False)  # noqa: S603
        print(
            f"Exit code {res.returncode}, "
            f"{len(res.stdout)} bytes of output, "
            f"{len(res.stderr)} bytes of error output\n",
        )
        if res.returncode == 0:
            raise TestError(cmd, "succeeded unexpectedly")
        if res.stdout:
            raise TestError(cmd, f"output something: {res.stdout!r}")
        if not res.stderr:
            raise TestError(cmd, "did not produce any error output")


def dirs_differ(src: pathlib.Path, dst: pathlib.Path) -> bool:
    """Run `diff -qr` on two directories, check for a non-zero result."""
    return subprocess.call(["diff", "-qr", "--", src, dst]) != 0  # noqa: S603,S607


def find_rsync() -> pathlib.Path:
    """Find an rsync executable in the current search path."""
    for path in (pathlib.Path(path) for path in os.environ["PATH"].split(os.pathsep)):
        rsync = path / "rsync"
        if rsync.is_file():
            return rsync

    sys.exit("Could not find an `rsync` executable in the search path")


def check_state(cmd: util.PathList, state_file: pathlib.Path) -> None:
    """Check the state after some invocations of the rsync wrapper."""
    state: Final = json.loads(state_file.read_text(encoding="UTF-8"))
    expected_steps: Final = util.expected_steps(pathlib.Path(state["src"]))
    if state["steps"] != expected_steps:
        raise TestError(
            cmd,
            f"expected {expected_steps} invocations of the rsync wrapper, got {state['steps']}",
        )


def write_config(
    tempd: pathlib.Path,
    case: TestCase,
    src: pathlib.Path,
    dst: pathlib.Path,
) -> pathlib.Path:
    """Write the appropriate config file."""
    if case.config == ConfigFileMode.FILE:
        cfgname = "{src}-{dst}.conf".format(
            src=case.src_slug.replace("/", "-"),
            dst=case.dst_slug.replace("/", "-"),
        )
    elif case.config == ConfigFileMode.HOME:
        cfgname = "home-nonempty/.config/repopush.conf"
    else:
        raise NotImplementedError(repr(case))

    cfgfile: Final = tempd / cfgname
    cfgfile.write_text(
        "".join(
            line + "\n"
            for line in [
                "[something]",
                "local=/nonexistent",
                "remote=rsync://127.0.0.1/remote",
                "",
                "[ours]",
                f"local={src}",
                f"remote={dst}",
            ]
        ),
        encoding="UTF-8",
    )
    return cfgfile


def test_run(  # noqa: C901
    cfg: Config,
    tempd: pathlib.Path,
    case: TestCase,
    *,
    fail: bool,
) -> None:
    """Test a repopush run."""
    print(
        f"\n\n=== Testing {case.src_slug} -> {case.dst_slug} "
        f"noop {case.noop} config {case.config} "
        f"\n\n",
    )
    src_template: Final = cfg.test_data / case.src_slug
    dst_template: Final = cfg.test_data / case.dst_slug

    with tempfile.TemporaryDirectory(
        dir=tempd,
        prefix="src-" + case.src_slug.replace("/", "-") + ".",
    ) as src_t:
        src: Final = pathlib.Path(src_t)
        subprocess.check_call(  # noqa: S603
            ["rsync", "-a", "--", f"{src_template}/", f"{src}/"],  # noqa: S607
        )

        with tempfile.TemporaryDirectory(
            dir=tempd,
            prefix="dst-" + case.dst_slug.replace("/", "-") + ".",
        ) as dst_t:
            dst: Final = pathlib.Path(dst_t)
            subprocess.check_call(  # noqa: S603
                ["rsync", "-a", "--", f"{dst_template}/", f"{dst}/"],  # noqa: S607
            )

            state_file: Final = tempd / "repopush-test-state.json"
            state_file.write_text(
                json.dumps(
                    {
                        "tempd": str(tempd),
                        "src": str(src),
                        "dst": str(dst),
                        "noop": case.noop,
                        "fail": fail,
                        "steps": 0,
                    },
                ),
                encoding="UTF-8",
            )
            case.env["REPOPUSH_TEST_STATE"] = str(state_file)

            cmd: util.PathList = [cfg.program]

            if case.config == ConfigFileMode.FILE:
                cmd.extend(["-s", "ours", "-f", write_config(tempd, case, src, dst)])
            elif case.config == ConfigFileMode.HOME:
                write_config(tempd, case, src, dst)
                cmd.extend(["-s", "ours"])

            cmd.extend((["-N"] if case.noop else []) + ["-v", "--"])
            if case.config == ConfigFileMode.NONE:
                cmd.extend([src, dst])

            if fail:
                print(f"Expect a failure from `{util.cmdstr(cmd)}`")
                if subprocess.call(cmd, env=case.env) == 0:  # noqa: S603
                    raise TestError(cmd, "did not fail")
                print("Failed as expected")

                print(f"Expect nothing to have changed in {src}")
                if dirs_differ(src_template, src):
                    raise TestError(cmd, "modified the source directory")

                return

            print(f"Expect success from `{util.cmdstr(cmd)}`")
            res: Final = subprocess.call(cmd, env=case.env)  # noqa: S603
            if res != 0:
                raise TestError(cmd, f"exited with code {res}")

            check_state(cmd, state_file)

            print(f"Expect nothing to have changed in {src}")
            if dirs_differ(src_template, src):
                raise TestError(cmd, "modified the source directory")

            if case.noop:
                print(f"Expect nothing to have changed in {dst}")
                if dirs_differ(dst_template, dst):
                    raise TestError(cmd, "modified the destination directory")
            else:
                print(f"Expect exactly the same files in {dst}")
                if dirs_differ(src, dst):
                    raise TestError(
                        cmd,
                        "did not make the destination the same as the source",
                    )


def do_test(cfg: Config, tempd: pathlib.Path) -> None:
    """Run the actual test using the specified configuration."""
    os.chdir(tempd)

    (tempd / "bin").mkdir(mode=0o755)
    (tempd / "bin/rsync").write_text(
        """#!/bin/sh

set -x
exec env PYTHONPATH={pythonpath} {python} -B -m repopush_test.rsync "$@"
""".format(
            pythonpath=os.environ.get("PYTHONPATH", ""),
            python=sys.executable,
        ),
        encoding="UTF-8",
    )
    (tempd / "bin/rsync").chmod(0o755)

    (tempd / "home-empty").mkdir(mode=0o755)
    (tempd / "home-nonempty").mkdir(mode=0o755)
    (tempd / "home-nonempty/.config").mkdir(mode=0o755)

    run_env = dict(os.environ)
    run_env["PATH"] = f"{tempd}/bin:{run_env['PATH']}"
    run_env["REPOPUSH_TEST_RSYNC"] = str(find_rsync())

    test_bad_args(cfg, tempd)

    def run_tests(slugs: tuple[str, str], fail: tuple[bool, bool]) -> None:
        """Run the src_slug -> dst_slug tests."""
        for cfile in ConfigFileMode:
            run_env["HOME"] = str(
                tempd / "home-nonempty" if cfile == ConfigFileMode.HOME else tempd / "home-empty",
            )
            for idx in (0, 1):
                test_run(
                    cfg,
                    tempd,
                    TestCase(
                        config=cfile,
                        env=run_env,
                        src_slug=slugs[0],
                        dst_slug=slugs[1],
                        noop=(idx == 0),
                    ),
                    fail=fail[idx],
                )

    for pair in PAIRS_BAD:
        run_tests(pair, (True, True))

    for pair in PAIRS_HALF_BAD:
        run_tests(pair, (False, True))

    for pair in PAIRS_GOOD:
        run_tests(pair, (False, False))

    print("Everything seems to be fine!")


def main() -> None:
    """Create directories, run tests."""
    # Some quick sanity checks
    if (
        any(len(pairs) != len(set(pairs)) for pairs in (PAIRS_BAD, PAIRS_HALF_BAD, PAIRS_GOOD))
        or len(PAIRS_BAD + PAIRS_HALF_BAD + PAIRS_GOOD) != PAIRS_COUNT_EXPECTED
        or len(set(PAIRS_BAD + PAIRS_HALF_BAD + PAIRS_GOOD)) != PAIRS_COUNT_EXPECTED
    ):
        raise RuntimeError(repr((PAIRS_BAD, PAIRS_HALF_BAD, PAIRS_GOOD)))

    # OK, let's get to it...
    cfg: Final = parse_args()
    with tempfile.TemporaryDirectory(prefix="repopush_test.") as tempd_t:
        tempd: Final = pathlib.Path(tempd_t)
        print(f"Using {tempd} as a temporary directory")
        try:
            do_test(cfg, tempd)
        except TestError as err:
            sys.exit(str(err))
        finally:
            os.chdir("/")


if __name__ == "__main__":
    main()
