# SPDX-FileCopyrightText: b5327157 <b5327157@protonmail.com>
#
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

import itertools
import os
import pathlib
import re
import shutil
import sys
import tempfile

from abc import abstractmethod
from collections.abc import Callable, Iterable, Iterator, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal, Optional, TypeAlias, TypeVar, Union, final

import nox
import nox.command

if sys.version_info >= (3, 11):
    from typing import Self
elif TYPE_CHECKING:
    from typing_extensions import Self

StrPath: TypeAlias = Union[str, os.PathLike[str]]

nox.needs_version = ">= 2023.4.22"

nox.options.default_venv_backend = "venv"
nox.options.error_on_external_run = True
nox.options.error_on_missing_interpreters = True
nox.options.sessions = ["lint"]


@nox.session(python="3.10")
def lint(session: nox.Session) -> None:
    session.install("black[colorama] >= 23.7.0, < 24")
    session.install("flake8 >= 6.1.0, < 7")
    session.install("flake8-builtins >= 2.1.0, < 3")
    session.install("flake8-deprecated >= 2.0.1, < 3")
    session.install("flake8-pep585 >= 0.1.7, < 1")
    session.install("isort[colors] >= 5.12.0, < 6")
    session.install("mypy >= 1.5.1, < 2")
    session.install("nox >= 2023.4.22, < 2024")
    session.install("pep8-naming >= 0.13.3, < 1")
    session.install("pyright >= 1.1.325, < 2")
    session.install("reuse >= 2.1.0, < 3")

    session.install("types-Pillow >= 10.0.0.2, < 11")

    session.install(".")

    posargs_paths = session.posargs
    fix = False
    if posargs_paths and posargs_paths[0] == "--fix":
        posargs_paths = posargs_paths[1:]
        fix = True

    paths = ["no_vtf", "noxfile.py", "ksy/compile.py"]
    if posargs_paths:
        paths = posargs_paths

    if not fix:
        session.run("mypy", "--pretty", "--show-error-context", "--", *paths)
        session.run("pyright", "--warnings", *paths, silent=True)
        session.run("flake8", "--", *paths)
        session.run("isort", "--check", "--diff", "--", *paths)
        session.run("black", "--check", "--diff", "--", *paths)
        session.run("reuse", "lint", silent=True)
    else:
        session.run("isort", "--", *paths)
        session.run("black", "--", *paths)


def coverage(session: nox.Session) -> None:
    session.install("coverage[toml] >= 7.3.0, < 8")
    session.install("tomli >= 2.0.1, < 3")

    session.install(".")

    coverage_run = ["-m", "no_vtf"]
    runner = CoverageTestRunner(coverage_run=coverage_run, paths=session.bin_paths, env=session.env)
    with TestSuite.with_archived_samples(runner=runner, session=session) as test_suite:
        test_suite.write()
        test_suite.readback()

    coverage = ["python", "-m", "coverage"]
    session.run(*coverage, "combine", "--quiet", "--append")
    session.run(*coverage, "html")

    if not session.posargs:
        return

    archive_name = session.posargs[0]
    make_archive(archive_name, base_dir="htmlcov")


if os.name == "posix":
    coverage = nox.session(python="3.10")(coverage)


@nox.session(python="3.10")
def package(session: nox.Session) -> None:
    path_dist = pathlib.Path("dist")
    if path_dist.is_dir():
        dist_files = [path for path in path_dist.iterdir() if path.is_file()]
        for dist_file in dist_files:
            dist_file.unlink()

    session.install("build >= 1.0.0, < 2")

    session.run("python", "-m", "build", silent=True)

    path_sdist = next(path_dist.glob("*.tar.gz"))
    path_wheel = next(path_dist.glob("*.whl"))

    # run even with the --no-install flag
    session.run("pip", "install", "--force-reinstall", str(path_wheel), silent=True)

    executable = ["python", "-I", "-m", "no_vtf"]
    session.run(*executable, "--version")

    runner = TestRunner(cmd=executable, env=session.env, paths=session.bin_paths)
    with TestSuite.with_archived_samples(runner=runner, session=session) as test_suite:
        test_suite.run([], {})

    if len(session.posargs) >= 1:
        shutil.copy2(path_sdist, session.posargs[0])

    if len(session.posargs) >= 2:
        shutil.copy2(path_wheel, session.posargs[1])


@nox.session(python="3.10")
def freeze(session: nox.Session) -> None:
    root_dir = pathlib.Path("dist")
    base_dir = pathlib.Path("no_vtf")

    session.install("pyinstaller >= 5.13.2, < 6")

    # installing in editable mode will ensure the _version.py file is generated by setuptools_scm
    # run even with the --no-install flag
    session.run("pip", "install", "--force-reinstall", "-e", ".", silent=True)

    session.run("imageio_download_bin", "--package-dir", silent=True)

    pyinstaller_datas: list[tuple[str, str]] = []
    pyinstaller_args: list[str] = []

    pyinstaller_args.extend(["--log-level", "WARN"])
    pyinstaller_args.extend(["--name", "no_vtf"])
    pyinstaller_args.extend(["--icon", "resources/pyinstaller/empty.ico"])

    if os.name == "posix":
        pyinstaller_datas.append(("resources/pyinstaller/no_vtf.desktop", "."))

    # https://github.com/rsalmei/alive-progress/issues/123
    pyinstaller_args.extend(["--collect-data", "grapheme"])

    # bundle plugin binary dependencies downloaded by imageio_download_bin
    pyinstaller_args.extend(["--collect-binaries", "imageio"])

    pyinstaller_args.append("--noconfirm")
    pyinstaller_args.append("--clean")

    for pyinstaller_data in pyinstaller_datas:
        pyinstaller_args.extend(["--add-data", os.pathsep.join(pyinstaller_data)])

    session.run("pyinstaller", *pyinstaller_args, "no_vtf/__main__.py")
    shutil.rmtree(root_dir / base_dir / "imageio/resources/images", ignore_errors=True)

    executable = str(root_dir / base_dir / "no_vtf")
    if os.name == "nt":
        executable += ".exe"

    paths = ["."]
    nox.command.run([executable, "--version"], paths=paths, external=True)

    runner = TestRunner(cmd=[executable], env=session.env, paths=paths, external=True)
    with TestSuite.with_archived_samples(runner=runner, session=session) as test_suite:
        test_suite.run([], {})

    if not session.posargs:
        return

    archive_name = session.posargs[0]
    make_archive(archive_name, root_dir=root_dir, base_dir=base_dir)


@nox.session(python="3.10")
def publish(session: nox.Session) -> None:
    if not session.posargs:
        session.error("Path to API token file was not provided")

    session.install("twine >= 4.0.2, < 5")

    dist = pathlib.Path("dist")
    dist_files = [path for path in dist.iterdir() if path.is_file()]
    dist_args = [str(path) for path in dist_files]

    session.run("twine", "check", "--strict", *dist_args)

    upload_args: list[str] = []
    upload_args.append("--non-interactive")
    upload_args.append("--disable-progress-bar")
    upload_args.extend(dist_args)

    env = session.env.copy()
    env["TWINE_USERNAME"] = "__token__"
    env["TWINE_PASSWORD"] = pathlib.Path(session.posargs[0]).read_text().strip()

    nox.command.run(
        ["twine", "upload", *upload_args],
        env=env,
        paths=session.bin_paths,
        external=True,
    )


@nox.session(python="3.10")
def write_reference(session: nox.Session) -> None:
    if not session.posargs:
        session.error("Path to test samples was not provided")

    # run even with the --no-install flag
    session.run("pip", "install", "--force-reinstall", ".", silent=True)

    executable = ["python", "-I", "-m", "no_vtf"]

    samples = pathlib.Path(session.posargs[0])

    runner = TestRunner(cmd=executable, env=session.env, paths=session.bin_paths)
    test_suite = TestSuite(runner=runner, samples=samples, session=session)
    test_suite.write()
    test_suite.readback()


@nox.session(python="3.10")
def extract_samples(session: nox.Session) -> None:
    if not session.posargs:
        session.error("Destination path was not provided")
    destination = pathlib.Path(session.posargs[0])

    with samples_archive_view() as samples:
        shutil.copytree(
            samples, destination, symlinks=True, copy_function=shutil.copy2, dirs_exist_ok=True
        )


@dataclass(frozen=True, kw_only=True)
class TestRunner:
    cmd: Sequence[str]
    env: Optional[dict[str, str]] = None
    paths: Optional[list[str]] = None
    external: Optional[Union[Literal["error"], bool]] = None

    def __call__(
        self,
        args: Sequence[str],
    ) -> Union[str, bool]:
        full_cmd = list(self.cmd) + list(args)

        external = self.external
        if external is None:
            external = "error" if nox.options.error_on_external_run else False

        return nox.command.run(full_cmd, env=self.env, paths=self.paths, external=external)


@dataclass(frozen=True, kw_only=True)
class CoverageTestRunner(TestRunner):
    coverage_run: Sequence[str]

    cmd: Sequence[str] = (
        "timeout",
        "--verbose",
        "--kill-after=10s",
        "5s",
        "python",
        "-m",
        "coverage",
        "run",
    )
    external: Optional[Union[Literal["error"], bool]] = True

    def __call__(
        self,
        args: Sequence[str],
    ) -> Union[str, bool]:
        args = list(self.coverage_run) + list(args)

        # registering a SIGTERM signal handler may cause the process to hang
        # (https://coverage.readthedocs.io/en/latest/config.html#run-sigterm)
        # this workaround is used to ensure eventual progress in case of hang
        for i in itertools.count(start=1):
            try:
                return super().__call__(args)
            except nox.command.CommandFailed as exception:
                if exception.reason != "Returned code 124" or i == 10:
                    raise exception
        assert False, "iterating over itertools.count() does not end"


@dataclass(kw_only=True)
class Test:
    runner: TestRunner
    samples: pathlib.Path

    @classmethod
    @contextmanager
    def with_archived_samples(cls, *, runner: TestRunner, **kwargs: object) -> Iterator[Self]:
        with samples_archive_view() as samples:
            test = cls(runner=runner, samples=samples, **kwargs)
            yield test

    def write(
        self, args: Optional[Sequence[object]] = None, opts: Optional[dict[str, object]] = None
    ) -> None:
        args = args or []

        opts = opts or {}
        opts = {"always_write": ..., "no_readback": ..., "no_write": None, "readback": None, **opts}

        self.run(args, opts)

    def readback(
        self, args: Optional[Sequence[object]] = None, opts: Optional[dict[str, object]] = None
    ) -> None:
        args = args or []

        opts = opts or {}
        opts = {"no_write": ..., "readback": ..., "always_write": None, "no_readback": None, **opts}

        self.run(args, opts)

    @final
    def run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        if not self._pre_run(args, opts):
            return

        exception: Optional[Exception] = None
        try:
            self._run(args, opts)
        except Exception as ex:
            exception = ex

        self._post_run(exception)

    def _pre_run(self, args: Sequence[object], opts: dict[str, object]) -> bool:
        return True

    @abstractmethod
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        ...

    def _post_run(self, exception: Optional[Exception]) -> None:
        if exception:
            raise exception


_TC = TypeVar("_TC", bound="TestCase")
_TestCaseRun: TypeAlias = Callable[[_TC, Sequence[object], dict[str, object]], None]


@dataclass(kw_only=True)
class TestCase(Test):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath(type(self).__name__)

    def get_input_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("input")

    def get_output_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("output")

    def get_test_samples(self) -> pathlib.Path:
        return self.samples / self.get_samples_subdir()

    def get_input_path(self, subpath: StrPath) -> pathlib.Path:
        return self.get_test_samples() / self.get_input_subdir() / subpath

    def get_output_path(self, subpath: StrPath) -> pathlib.Path:
        return self.get_test_samples() / self.get_output_subdir() / subpath

    @staticmethod
    def and_(
        *decorators: Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                nonlocal run
                for decorator in decorators:
                    run = decorator(run)

                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def or_(
        *decorators: Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                for decorator in decorators:
                    decorator(run)(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def with_common_options(
        compress: Optional[bool] = False,
        no_progress: bool = True,
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                compress_value = ... if compress is True else None
                no_compress_value = ... if compress is False else None
                no_progress_value = ... if no_progress is True else None

                opts = {
                    "compress": compress_value,
                    "no_compress": no_compress_value,
                    "no_progress": no_progress_value,
                    **opts,
                }
                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def rw_mode(
        write: Optional[bool] = False,
        readback: bool = False,
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                if write is True:
                    opts = {"always_write": ..., "no_write": None, **opts}
                elif write is False:
                    opts = {"no_write": ..., "always_write": None, **opts}

                if readback is True:
                    opts = {"readback": ..., "no_readback": None, **opts}
                elif readback is False:
                    opts = {"no_readback": ..., "readback": None, **opts}

                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def with_inputs(
        subpaths: Optional[Sequence[StrPath]] = None,
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                nonlocal subpaths
                subpaths = subpaths or [pathlib.PurePath()]
                input_paths = [self.get_input_path(subpath) for subpath in subpaths]

                args = input_paths + list(args)
                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def in_place() -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                opts = {"output_dir": None, "output_file": None, **opts}
                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def to_directory(
        subpath: Optional[StrPath] = None,
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                nonlocal subpath
                subpath = subpath or pathlib.PurePath()
                output_path = self.get_output_path(subpath)

                opts = {"output_dir": output_path, "output_file": None, **opts}
                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def to_file(
        subpath: StrPath,
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                output_path = self.get_output_path(subpath)

                opts = {"output_file": output_path, "output_dir": None, **opts}
                run(self, args, opts)

            return inner

        return decorator

    @staticmethod
    def parametrize(
        *opt_sets: dict[str, object]
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                for opt_set in opt_sets:
                    parametrized_opts = {**opt_set, **opts}
                    run(self, args, parametrized_opts)

            return inner

        return decorator

    @staticmethod
    def test_filter(  # noqa: C901
        option: str,
        subpath: StrPath,
        num_items: int,
        *,
        animate: Optional[bool] = None,
        ldr_format: Optional[str] = None,
        hdr_format: Optional[str] = "skip",
    ) -> Callable[[_TestCaseRun[_TC]], _TestCaseRun[_TC]]:
        def decorator(run: _TestCaseRun[_TC]) -> _TestCaseRun[_TC]:
            def inner(self: _TC, args: Sequence[object], opts: dict[str, object]) -> None:
                input_file = self.get_input_path(subpath)
                args = [input_file] + list(args)

                nonlocal ldr_format
                ldr_format = ldr_format or ("apng" if animate else "tiff")
                opts = {"ldr_format": ldr_format, **opts}

                if hdr_format:
                    opts = {"hdr_format": hdr_format, **opts}

                if animate is True:
                    opts = {"animate": ..., **opts}
                elif animate is False:
                    opts = {"no_animate": ..., **opts}

                indices_pos = [1, num_items - 1]
                indices_neg = [-index for index in indices_pos]
                indices = [None] + indices_pos + indices_neg
                steps = [None, -2]

                def iter_run(filter_str: str) -> None:
                    iter_opts = {option: filter_str, **opts}

                    iter_subpath = pathlib.Path(subpath)
                    filter_path = filter_str.replace(":", ",")
                    output_stem = f"{iter_subpath.stem}_{filter_path}"

                    if animate:
                        output_subpath = iter_subpath.with_name(f"{output_stem}.{ldr_format}")
                        output_file = self.get_output_path(output_subpath)

                        iter_opts = {"output_file": output_file, **iter_opts}
                    else:
                        output_dir = self.get_output_path(output_stem)
                        output_dir.mkdir(parents=True, exist_ok=True)

                        iter_opts = {"output_dir": output_dir, **iter_opts}

                    run(self, args, iter_opts)

                for index in indices:
                    index = index or 0
                    if index < -num_items or index >= num_items:
                        continue

                    iter_run(str(index))

                for start, stop, step in itertools.product(indices, indices, steps):
                    if not len(range(num_items)[slice(start, stop, step)]):
                        continue

                    def str_optional(o: Optional[object], /) -> str:
                        return str(o) if o is not None else ""

                    filter_str = ""
                    filter_str += f"{str_optional(start)}:{str_optional(stop)}"
                    if step is not None:
                        filter_str += f":{str_optional(step)}"

                    iter_run(filter_str)

            return inner

        return decorator

    @with_common_options()
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        assert not (opts.get("compress") and opts.get("no_compress"))
        assert not (opts.get("always_write") and opts.get("no_write"))
        assert not (opts.get("readback") and opts.get("no_readback"))

        parameters = self._str_opts(opts) + self._str_args(args)
        self.runner(parameters)

    def _str_opts(self, opts: dict[str, object]) -> list[str]:
        str_opts: list[str] = []
        for name, value in opts.items():
            if value is None:
                continue

            str_opt = "--" + name.replace("_", "-")
            str_opts.append(str_opt)

            if value is not Ellipsis:
                value = str(value)
                str_opts.append(value)

        return str_opts

    def _str_args(self, args: Sequence[object]) -> list[str]:
        if not args:
            return []

        str_args = ["--"]
        str_args.extend(str(arg) for arg in args)
        return str_args


@dataclass(kw_only=True)
class TestSuite(Test):
    test_classes: Optional[Sequence[type[TestCase]]] = None

    session: Optional[nox.Session] = None

    @classmethod
    def register(cls, test_class: type[_TC]) -> type[_TC]:
        cls._registered_test_classes.append(test_class)
        return test_class

    _registered_test_classes: ClassVar[list[type[TestCase]]] = []

    def __post_init__(self) -> None:
        self._test_instances: list[TestCase] = []

        test_classes = self.test_classes or self._registered_test_classes
        for test_class in test_classes:
            test_instance = test_class(runner=self.runner, samples=self.samples)
            self._test_instances.append(test_instance)

    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        for test_instance in self._test_instances:
            if self.session:
                self.session.log(f"Running {type(test_instance).__name__}: {args = } {opts = }")

            test_instance.run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class InfoTest(TestCase):
    @TestCase.or_(  # type: ignore[arg-type] # mypy mistakenly reports error
        TestCase.parametrize({"help": ...}),
        TestCase.parametrize({"version": ...}),
        TestCase.parametrize({"credits": ...}),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class WriteSelfTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    def get_output_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("write_self_test_output")

    def _pre_run(self, args: Sequence[object], opts: dict[str, object]) -> bool:
        if "no_write" in opts and opts["no_write"] is not None:
            return False

        output_path = self.get_output_path("")
        shutil.rmtree(output_path, ignore_errors=True)
        output_path.mkdir()

        return True

    @TestCase.rw_mode(write=None)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)

    def _post_run(self, exception: Optional[Exception]) -> None:
        super()._post_run(exception)

        input_path = self.get_input_path("")
        output_path = self.get_output_path("")

        num_output_files_expected = 0
        for path in input_path.iterdir():
            if not path.is_file():
                continue

            stem_re = re.escape(path.stem)
            extension_re = r"\.[^\./]+$"

            envmap_6_faces_re = r"(?:bk|dn|ft|lf|rt|up)"
            envmap_7_faces_re = r"(?:" + envmap_6_faces_re + "|sph)"
            match path.stem:
                case "envmap_6_faces":
                    additional_re = envmap_6_faces_re
                case "envmap_7_faces":
                    additional_re = envmap_7_faces_re
                case _:
                    additional_re = r""

            pattern = re.compile(stem_re + additional_re + extension_re, re.ASCII)

            output_files = list(path_re_glob(output_path, pattern))
            num_output_files_expected += len(output_files)

        num_output_files_actual = len([path for path in output_path.iterdir() if path.is_file()])
        assert (
            num_output_files_actual == num_output_files_expected
        ), "Number of expected and actual written files must match"

        shutil.rmtree(output_path)


@TestSuite.register
@dataclass(kw_only=True)
class ReadbackNegativeSelfTest(TestCase):
    def get_input_path(self, subpath: StrPath) -> pathlib.Path:
        return self.samples / subpath

    def get_output_path(self, subpath: StrPath) -> pathlib.Path:
        return self.samples / subpath

    def _pre_run(self, args: Sequence[object], opts: dict[str, object]) -> bool:
        return not any(
            (
                ("no_write" in opts and opts["no_write"] is None),
                ("readback" in opts and opts["readback"] is None),
                ("always_write" in opts and opts["always_write"] is not None),
                ("no_readback" in opts and opts["no_readback"] is not None),
            )
        )

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/rgb888.vtf"]),
            TestCase.to_directory("FormatTest/nonexistent_directory"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/rgb888.vtf"]),
            TestCase.to_file("FormatTest/output/nonexistent_file.tiff"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/rgb888.vtf"]),
            TestCase.to_file("FormatTest/output/rgba8888.tiff"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/bgra8888.vtf"]),
            TestCase.to_file("FormatTest/output/bgra8888_hdr.tiff"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["MultipleFramesTest/input/8x8_.vtf"]),
            TestCase.to_file("MultipleFramesTest/output/8x8__,-1.apng"),
            TestCase.parametrize({"animate": ..., "ldr_format": "apng"}),
        ),
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/rgb888.vtf"]),
            TestCase.to_file("FormatTest/output/rgba8888.raw"),
            TestCase.parametrize({"raw": ...}),
        ),
        TestCase.and_(
            TestCase.with_inputs(["FormatTest/input/i8.vtf"]),
            TestCase.to_file("FormatTest/output/i8_extra_null_byte.raw"),
            TestCase.parametrize({"raw": ...}),
        ),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        exception: Optional[Exception] = None
        try:
            super()._run(args, opts)
        except Exception as ex:
            exception = ex

        assert isinstance(exception, nox.command.CommandFailed)
        assert exception.reason in ("Returned code 1", "Returned code 2")


@TestSuite.register
@dataclass(kw_only=True)
class FormatTest(TestCase):
    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.parametrize({}, {"mipmaps": ...})
    @TestCase.parametrize({}, {"separate_channels": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        ldr_formats = ["png", "tga", "tiff", "apng"]
        for ldr_format in ldr_formats:
            super()._run(args, {"ldr_format": ldr_format, "hdr_format": "skip", **opts})

        hdr_formats = ["exr", "tiff"]
        for hdr_format in hdr_formats:
            super()._run(args, {"ldr_format": "skip", "hdr_format": hdr_format, **opts})

        super()._run(args, {"raw": ..., **opts})


@TestSuite.register
@dataclass(kw_only=True)
class MultipleFramesTest(TestCase):
    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(["8x8_.vtf"]),
            TestCase.to_directory(),
            TestCase.parametrize({"no_animate": ...}),
            TestCase.parametrize({}, {"mipmaps": ...}),
            TestCase.parametrize({}, {"separate_channels": ...}),
        ),
        TestCase.test_filter("frames", "8x8_.vtf", 64, animate=False),
        TestCase.test_filter("frames", "8x8_.vtf", 64, animate=True),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class MultipleSlicesTest(TestCase):
    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(["volume.vtf"]),
            TestCase.to_directory(),
            TestCase.parametrize({}, {"mipmaps": ...}),
            TestCase.parametrize({}, {"separate_channels": ...}),
        ),
        TestCase.test_filter("slices", "volume.vtf", 256),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class MultipleFacesTest(TestCase):
    @TestCase.rw_mode(readback=True)
    @TestCase.test_filter("faces", "envmap_7_faces.vtf", 7)
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class VersionTest(TestCase):
    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.with_inputs(["rgb888_v7.0.vtf"]),
        TestCase.with_inputs(["rgb888_v7.1.vtf"]),
        TestCase.with_inputs(["rgb888_v7.2.vtf"]),
        TestCase.with_inputs(["rgb888_v7.3.vtf"]),
        TestCase.with_inputs(["rgb888_v7.4.vtf"]),
        TestCase.with_inputs(["rgb888_v7.5.vtf"]),
    )
    @TestCase.to_directory()
    @TestCase.parametrize({}, {"mipmaps": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class LowResolutionImageTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("VersionTest")

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.with_inputs(["rgb888_v7.0.vtf"]),
        TestCase.with_inputs(["rgb888_v7.1.vtf"]),
        TestCase.with_inputs(["rgb888_v7.2.vtf"]),
        TestCase.with_inputs(["rgb888_v7.3.vtf"]),
        TestCase.with_inputs(["rgb888_v7.4.vtf"]),
        TestCase.with_inputs(["rgb888_v7.5.vtf"]),
    )
    @TestCase.to_directory("low_res_img")
    @TestCase.parametrize({"low_res_img": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class PathTest(TestCase):
    def get_input_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath()

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(
                [
                    "input/a.vtf/b/c.vtf",
                    "input/a.vtf",
                    "input/a.vtf/b",
                    "input",
                    "input/a.vtf/b/c.vtf",
                ]
            ),
            TestCase.to_directory("to_directory"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["input/a.vtf/b/c.vtf"]),
            TestCase.to_file("to_file/c.tiff"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["in_place"]),
            TestCase.in_place(),
        ),
    )
    @TestCase.parametrize({"ldr_format": "png", "hdr_format": "skip"})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class UnsupportedFormatTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.parametrize({"ldr_format": "ppm", "hdr_format": "skip", "separate_channels": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class SequentialRunnerTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.parametrize({"num_workers": 1})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class ResolutionFilterTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.with_inputs(["rgb888.vtf"])
    @TestCase.or_(
        TestCase.and_(
            TestCase.to_file("rgb888_min_512px.tiff"),
            TestCase.parametrize({"min_resolution": 512}),
        ),
        TestCase.and_(
            TestCase.to_file("rgb888_min_2048px_closest.tiff"),
            TestCase.parametrize({"min_resolution": 2048, "closest_resolution": ...}),
        ),
        TestCase.and_(
            TestCase.to_file("rgb888_max_256px.tiff"),
            TestCase.parametrize({"max_resolution": 256}),
        ),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class ProgressBarTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.with_common_options(no_progress=False)
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class HdrToLdrTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(
                [
                    "bgra8888_hdr.vtf",
                    "rgb0-rgba16161616f_hdr.vtf",
                    "rgba16161616f_hdr.vtf",
                    "rgba16161616_hdr.vtf",
                ]
            ),
            TestCase.to_directory("hdr_to_ldr"),
        ),
        TestCase.and_(
            TestCase.with_inputs(["bgra8888.vtf"]),
            TestCase.to_directory(),
        ),
    )
    @TestCase.parametrize({"hdr_to_ldr": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class RawIsUnaffectedByOtherFlagsTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.parametrize({"raw": ...})
    @TestCase.parametrize({"hdr_to_ldr": ..., "separate_channels": ...})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class PipelineStepsTest(TestCase):
    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.with_inputs()
    @TestCase.to_directory()
    @TestCase.parametrize({"no_animate": ...}, {"ldr_format": "apng"})
    @TestCase.or_(
        TestCase.parametrize({}, {"raw": ...}),
        TestCase.and_(
            TestCase.parametrize({}, {"hdr_to_ldr": ...}),
            TestCase.parametrize({}, {"separate_channels": ...}),
        ),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class DynamicRangeOverrideTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)  # type: ignore[arg-type] # mypy mistakenly reports error
    @TestCase.or_(
        TestCase.and_(
            TestCase.with_inputs(["bgra8888.vtf"]),
            TestCase.to_file("bgra8888_hdr.exr"),
            TestCase.parametrize({"dynamic_range": "hdr"}),
        ),
        TestCase.and_(
            TestCase.with_inputs(["bgra8888_hdr.vtf"]),
            TestCase.to_file("bgra8888.tiff"),
            TestCase.parametrize({"dynamic_range": "ldr"}),
        ),
    )
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@TestSuite.register
@dataclass(kw_only=True)
class OverbrightFactorTest(TestCase):
    def get_samples_subdir(self) -> pathlib.PurePath:
        return pathlib.PurePath("FormatTest")

    @TestCase.rw_mode(readback=True)
    @TestCase.with_inputs(["bgra8888_hdr.vtf"])
    @TestCase.to_file("bgra8888_hdr_overbright_factor_8.exr")
    @TestCase.parametrize({"overbright_factor": 8})
    def _run(self, args: Sequence[object], opts: dict[str, object], /) -> None:
        super()._run(args, opts)


@contextmanager
def samples_archive_view() -> Iterator[pathlib.Path]:
    def walk_onerror(exception: OSError) -> None:
        raise exception

    with tempfile.TemporaryDirectory() as samples_directory_name:
        samples_directory = pathlib.Path(samples_directory_name)
        top = pathlib.Path("resources/test/samples")

        for dirpath, _, filenames in os.walk(top, onerror=walk_onerror):
            top_to_dirpath = pathlib.Path(dirpath).relative_to(top)
            extract_dir = samples_directory / top_to_dirpath
            extract_dir.mkdir(parents=True, exist_ok=True)

            for filename in sorted(filenames):
                if not filename.endswith(".license"):
                    full_path = os.path.join(dirpath, filename)
                    shutil.unpack_archive(full_path, extract_dir)

        yield samples_directory


def make_archive(
    archive_name: str,
    *,
    root_dir: Optional[StrPath] = None,
    base_dir: Optional[StrPath] = None,
    dry_run: bool = False,
    owner: Optional[str] = None,
    group: Optional[str] = None,
    logger: object = None,
) -> str:
    archive_base_name: str
    archive_format: str
    if (archive_base_name := archive_name.removesuffix(".tar.xz")) != archive_name:
        archive_format = "xztar"
    elif (archive_base_name := archive_name.removesuffix(".zip")) != archive_name:
        archive_format = "zip"
    else:
        raise RuntimeError("Unsupported archive format")

    return shutil.make_archive(
        archive_base_name,
        archive_format,
        root_dir=root_dir,
        base_dir=base_dir,
        dry_run=dry_run,
        owner=owner,
        group=group,
        logger=logger,
    )


def path_re_glob(path: pathlib.Path, pattern: re.Pattern[str]) -> Iterable[pathlib.Path]:
    return (path for path in path.iterdir() if pattern.fullmatch(path.name))
