"""
(C) 2026 Jack Lloyd
(C) 2026 René Meusel, Rohde & Schwarz Cybersecurity

Botan is released under the Simplified BSD License (see license.txt)
"""

import json
import os
import unittest
from abc import ABC, abstractmethod
from collections import Counter
from urllib.request import urlopen
from typing import BinaryIO
from pathlib import Path

import botan3 as botan


class WycheproofTextTestResult(unittest.TextTestResult):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._skip_reason_counts: Counter[str] = Counter()

    def addSkip(self, test, reason):
        self._skip_reason_counts[str(reason)] += 1
        super().addSkip(test, reason)

    def stopTestRun(self):
        super().stopTestRun()

        if not self._skip_reason_counts:
            return

        self.stream.writeln("")
        self.stream.writeln("Skipped tests summary (by reason):")

        for reason, count in self._skip_reason_counts.most_common():
            self.stream.writeln(f"  - {count}x: {reason}")


# When these Wycheproof tests are run via `python -m unittest`, the CLI uses
# `unittest.TextTestRunner` and its `resultclass`. Since all tests import this
# module, we can centrally augment the default output without changing each test.
if getattr(unittest.TextTestRunner, "resultclass", None) is unittest.TextTestResult:
    unittest.TextTestRunner.resultclass = WycheproofTextTestResult


class FixedOutputRNG(botan.RandomNumberGenerator):
    def __init__(self, entropy_pool: bytes = b""):
        """
        A random number generator that outputs a pre-determined sequence of bytes.
        This is exclusively useful for testing.
        """
        super().__init__(
            "custom", get_callback=self._get, add_entropy_callback=self._add_entropy
        )
        self._entropy_pool = entropy_pool

    def _get(self, length: int) -> bytes:
        if length > len(self._entropy_pool):
            raise ValueError("Not enough entropy in pool")
        entropy = self._entropy_pool[:length]
        self._entropy_pool = self._entropy_pool[length:]
        return entropy

    def _add_entropy(self, data: bytes) -> None:
        self._entropy_pool += data


# An RNG implementation called NullRNG that raises an exception if it is used
class NullRNG(botan.RandomNumberGenerator):
    def __init__(self):
        super().__init__(
            "custom", get_callback=self._get, add_entropy_callback=self._add_entropy
        )

    def _get(self, length: int) -> bytes:
        raise botan.BotanException("Unexpected request to get entropy from RNG", rc=-23)

    def _add_entropy(self, data: bytes) -> None:
        raise botan.BotanException("Unexpected request to add entropy to RNG", rc=-23)


class WycheproofTests(ABC):
    """
    Base mixin class for Wycheproof tests.

    This class is intended to be used as a mixin with unittest.TestCase.
    It explicitly relies on methods provided by unittest.TestCase

        class MyTest(WycheproofTests, unittest.TestCase):
            ...
    """

    @abstractmethod
    def input_files(self) -> list[str]:
        """
        Return the list of input files to test. Those files are expected to
        be in the `testvectors_v1` directory of the Wycheproof repository.
        """

    @abstractmethod
    def run_test(self, data: dict, group: dict, test: dict) -> None:
        """
        Run a single test.
        Args:
            data: The data for the test. This is the entire JSON file.
            group: The group for the test. This is the group of test vectors.
            test: The test itself. This is the individual test vector.
        """

    @staticmethod
    def _get_cached_or_downloaded_file(filename: str) -> BinaryIO:
        """
        Returns an open file handle to the JSON file with given filename.
        If WYCHEPROOF_TESTDATA_CACHE_DIR is set, the file is cached in that directory.
        """

        base_url = os.environ.get("WYCHEPROOF_TESTDATA_URL")
        if base_url is None:
            raise RuntimeError("Environment variable WYCHEPROOF_TESTDATA_URL not set")
        url = f"{base_url}/{filename}"

        # Check if the cache can be used
        cache_dir = os.environ.get("WYCHEPROOF_TESTDATA_CACHE_DIR")
        if cache_dir is not None:
            cache_path = Path(cache_dir)
            cache_path.mkdir(parents=True, exist_ok=True)
            cache_entry = cache_path / filename
            if not cache_entry.exists():
                with urlopen(url, timeout=30) as response:
                    with open(cache_entry, "wb") as f:
                        f.write(response.read())
            return open(cache_entry, mode="rb")
        return urlopen(url, timeout=30)

    @staticmethod
    def _read_datafile(filename: str) -> dict | None:
        """
        Reads the datafile named 'filename' from cache (if available) or downloads and caches it.
        Returns the loaded JSON content.
        """
        with WycheproofTests._get_cached_or_downloaded_file(filename) as f:
            return json.load(f) if f is not None else None

    def _validate_unittest_mixin(self) -> None:
        if not isinstance(self, unittest.TestCase):
            raise TypeError(
                f"{self.__class__.__name__} must be used as a mixin with unittest.TestCase"
            )

    def _wycheproof_subtest(self, test: dict, filename: str):
        self._validate_unittest_mixin()
        params = {}
        tc_id = test.get("tcId")
        if tc_id is not None:
            params["tcId"] = tc_id
        comment = test.get("comment")
        if comment:
            params["comment"] = comment
        flags = test.get("flags")
        if flags:
            params["flags"] = ",".join(flags)
        params["filename"] = filename
        return unittest.TestCase.subTest(self, **params)

    def test_wycheproof(self) -> None:
        self._validate_unittest_mixin()
        for filename in self.input_files():
            data = self._read_datafile(filename)
            for group in data["testGroups"]:
                for test in group["tests"]:
                    with self._wycheproof_subtest(test, filename):
                        self.run_test(data, group, test)
