"""A test framework for the repopush tool."""

import argparse
import enum
import json
import os
import pathlib
import subprocess
import sys
import tempfile

from typing import Dict, List, NamedTuple, Tuple

from . import util


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

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

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

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


class Config(NamedTuple):
    """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(str, enum.Enum):
    """Whether 'tis better to create a config file or not..."""

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


class TestCase(NamedTuple):
    """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 = argparse.ArgumentParser(prog="repopush_test")
    parser.add_argument(
        "-p",
        "--program",
        type=pathlib.Path,
        required=True,
        help="the path to the repopush program to test",
    )
    parser.add_argument(
        "-t",
        "--test-data",
        type=pathlib.Path,
        required=True,
        help="the path to the test data directory",
    )

    args = parser.parse_args()

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

    test_data: pathlib.Path = args.test_data
    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 = (
        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")
        proc = subprocess.Popen(
            cmd,
            bufsize=0,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        output = proc.communicate()
        res = proc.wait()
        print(
            f"Exit code {res}, "
            f"{len(output[0])} bytes of output, "
            f"{len(output[1])} bytes of error output\n"
        )
        if res == 0:
            raise TestError(cmd, "succeeded unexpectedly")
        if output[0]:
            raise TestError(cmd, f"output something: {output[0]!r}")
        if not output[1]:
            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


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 = json.loads(state_file.read_text(encoding="UTF-8"))
    expected_steps = util.expected_steps(pathlib.Path(state["src"]))
    if state["steps"] != expected_steps:
        raise TestError(
            cmd,
            f"expected {expected_steps} invocations of "
            f"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(f"Internal error: write_config({case!r})")

    cfgfile = 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(
    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 = cfg.test_data / case.src_slug
    dst_template = cfg.test_data / case.dst_slug

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

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

            state_file = 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:
                    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 = subprocess.call(cmd, env=case.env)
            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 main() -> None:
    """Main program: create directories, run tests."""
    # Some quick sanity checks
    assert all(
        len(pairs) == len(set(pairs))
        for pairs in (PAIRS_BAD, PAIRS_HALF_BAD, PAIRS_GOOD)
    )
    assert len(PAIRS_BAD + PAIRS_HALF_BAD + PAIRS_GOOD) == 16
    assert len(set(PAIRS_BAD + PAIRS_HALF_BAD + PAIRS_GOOD)) == 16

    # OK, let's get to it...
    cfg = parse_args()
    with tempfile.TemporaryDirectory(prefix="repopush_test.") as tempd_t:
        tempd = pathlib.Path(tempd_t)
        print(f"Using {tempd} as a temporary directory")
        try:
            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["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"] = "{tempd}/bin:{path}".format(
                tempd=tempd, path=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[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!")
        except TestError as err:
            sys.exit(err)
        finally:
            os.chdir("/")


if __name__ == "__main__":
    main()
