import math
import os
import random
import secrets
import struct
from collections.abc import Callable, Sequence
from functools import wraps
from typing import Any, TypeVar

import capnp


T = TypeVar("T")

_EDGE_SLOTS = 16
_MINIMAL_EXAMPLES = 10
_INTEGER_RANGES = {
  "int8": (-2**7, 2**7 - 1),
  "int16": (-2**15, 2**15 - 1),
  "int32": (-2**31, 2**31 - 1),
  "int64": (-2**63, 2**63 - 1),
  "uint8": (0, 2**8 - 1),
  "uint16": (0, 2**16 - 1),
  "uint32": (0, 2**32 - 1),
  "uint64": (0, 2**64 - 1),
}

# One seed is shared by the whole test process. Individual tests derive their seed
# from their unittest ID, so FUZZ_SEED is reproducible under the parallel runner too.
FUZZ_SEED = int(os.environ.get("FUZZ_SEED", secrets.randbits(64)))


class Fuzzy:
  """Fast, deterministic data generator with systematic boundary coverage."""

  def __init__(self, seed: int | str, example_index: int):
    self.example_index = example_index
    self._random = random.Random(seed)
    self._draw_index = 0

  def _draw(self, edges: Sequence[T], random_value: Callable[[], T]) -> T:
    draw_index = self._draw_index
    self._draw_index += 1

    # Preserve the cheap minimal prefix Hypothesis produced, then interleave
    # systematic boundaries and random values at every draw site.
    if self.example_index < _MINIMAL_EXAMPLES:
      return edges[0]
    search_example = self.example_index - _MINIMAL_EXAMPLES
    if search_example < _EDGE_SLOTS * 2 and search_example % 2 == 0:
      return edges[(search_example // 2 + draw_index) % len(edges)]
    if self._random.randrange(4) == 0:
      return self._random.choice(edges)
    return random_value()

  def boolean(self) -> bool:
    return self._draw((False, True), lambda: bool(self._random.getrandbits(1)))

  def choice(self, values: Sequence[T]) -> T:
    if not values:
      raise ValueError("cannot choose from an empty sequence")
    return self._draw(values, lambda: self._random.choice(values))

  def integer(self, min_value: int, max_value: int) -> int:
    if min_value > max_value:
      raise ValueError(f"{min_value=} must not exceed {max_value=}")

    edges = [
      0, 1, -1, min_value, max_value,
      min_value + 1, max_value - 1,
    ]
    edges.extend(1 << bit for bit in range(max_value.bit_length()))
    edges.extend(-(1 << bit) for bit in range((-min_value).bit_length()))
    valid_edges = tuple(dict.fromkeys(v for v in edges if min_value <= v <= max_value))
    return self._draw(valid_edges, lambda: self._random.randint(min_value, max_value))

  def floating(self, width: int = 64, *, allow_nan: bool = True, allow_infinity: bool = True) -> float:
    if width not in (32, 64):
      raise ValueError("float width must be 32 or 64")

    if width == 32:
      unpack_format = "!f"
      finite_edges = (
        0.0, -0.0, 1.0, -1.0,
        struct.unpack(unpack_format, b"\x00\x00\x00\x01")[0],
        struct.unpack(unpack_format, b"\x80\x00\x00\x01")[0],
        struct.unpack(unpack_format, b"\x7f\x7f\xff\xff")[0],
        struct.unpack(unpack_format, b"\xff\x7f\xff\xff")[0],
        struct.unpack(unpack_format, b"\x00\x80\x00\x00")[0],
        struct.unpack(unpack_format, b"\x80\x80\x00\x00")[0],
      )
    else:
      unpack_format = "!d"
      finite_edges = (
        0.0, -0.0, 1.0, -1.0,
        math.ulp(0.0), -math.ulp(0.0),
        float.fromhex("0x1.fffffffffffffp+1023"), -float.fromhex("0x1.fffffffffffffp+1023"),
        float.fromhex("0x1p-1022"), -float.fromhex("0x1p-1022"),
      )

    edges = list(finite_edges)
    if allow_infinity:
      edges.extend((math.inf, -math.inf))
    if allow_nan:
      edges.append(math.nan)

    def random_float() -> float:
      while True:
        value = struct.unpack(unpack_format, self._random.randbytes(width // 8))[0]
        if (allow_nan or not math.isnan(value)) and (allow_infinity or not math.isinf(value)):
          return value

    return self._draw(tuple(edges), random_float)

  def _length(self, min_length: int, max_length: int | None) -> int:
    if min_length < 0:
      raise ValueError("minimum length must be non-negative")
    if max_length is not None and min_length > max_length:
      raise ValueError(f"{min_length=} must not exceed {max_length=}")
    if max_length == min_length:
      return min_length

    offsets = (0, 1, 2, 4, 8, 16, 32)
    edges = tuple(min_length + offset for offset in offsets if max_length is None or min_length + offset <= max_length)

    def random_length() -> int:
      # A geometric tail keeps ordinary examples small without placing an
      # artificial ceiling on an unbounded list.
      length = min_length
      while max_length is None or length < max_length:
        if self._random.randrange(8) == 0:
          break
        length += 1
      return length

    return self._draw(edges, random_length)

  def binary(self, min_size: int = 0, max_size: int | None = None) -> bytes:
    size = self._length(min_size, max_size)
    patterns = (
      bytes(size),
      b"\xff" * size,
      (b"\xaa\x55" * ((size + 1) // 2))[:size],
      bytes(i & 0xff for i in range(size)),
    )
    return self._draw(patterns, lambda: self._random.randbytes(size))

  def text(self, min_size: int = 0, max_size: int | None = None) -> str:
    size = self._length(min_size, max_size)

    def scalar() -> str:
      value = self._random.randrange(0x110000 - 0x800)
      if value >= 0xd800:
        value += 0x800
      return chr(value)

    patterns = (
      "",
      "a" * size,
      "\0" * size,
      "\U0010ffff" * size,
    )
    valid_patterns = tuple(value for value in patterns if len(value) == size)
    return self._draw(valid_patterns, lambda: "".join(scalar() for _ in range(size)))

  def list(self, generate: Callable[[], T], min_size: int = 0, max_size: int | None = None) -> list[T]:
    return [generate() for _ in range(self._length(min_size, max_size))]


def fuzzy_test(max_examples: int) -> Callable[[Callable[..., None]], Callable[..., None]]:
  """Run a unittest method repeatedly with independent, reproducible fuzzy data."""
  max_examples = int(os.environ.get("MAX_EXAMPLES", max_examples))
  assert max_examples >= 1

  def decorator(fn: Callable[..., None]) -> Callable[..., None]:
    @wraps(fn)
    def wrapper(*args: Any, **kwargs: Any) -> None:
      test_seed = f"{FUZZ_SEED}:{args[0].id()}"
      selected_example = os.environ.get("FUZZ_EXAMPLE")
      examples = [int(selected_example, 0)] if selected_example is not None else range(max_examples)

      for example_index in examples:
        if not 0 <= example_index < max_examples:
          raise ValueError(f"FUZZ_EXAMPLE={example_index} is outside [0, {max_examples})")
        try:
          fn(*args, **kwargs, fuzzy=Fuzzy(f"{test_seed}:{example_index}", example_index))
        except Exception as exc:
          exc.add_note(f"reproduce with FUZZ_SEED={FUZZ_SEED} FUZZ_EXAMPLE={example_index}")
          raise

    return wrapper
  return decorator


def capnp_random_dict(fuzzy: Fuzzy, schema: Any, event: str | None = None, *, real_floats: bool = False) -> dict[str, Any]:
  """Generate a dictionary accepted by a pycapnp struct constructor."""

  def native(type_name: str) -> bool | int | float | str | bytes:
    if type_name == "bool":
      return fuzzy.boolean()
    if type_name in _INTEGER_RANGES:
      return fuzzy.integer(*_INTEGER_RANGES[type_name])
    if type_name in ("float32", "float64"):
      return fuzzy.floating(width=int(type_name[-2:]), allow_nan=not real_floats, allow_infinity=not real_floats)
    if type_name == "text":
      return fuzzy.text(max_size=1000)
    if type_name == "anyPointer":
      return fuzzy.text()
    if type_name == "data":
      return fuzzy.binary(max_size=1000)
    raise NotImplementedError(f"invalid Cap'n Proto type: {type_name}")

  def generate_field(field: Any) -> Any:
    def rec(field_type: Any, base_type: str) -> Any:
      type_name = field_type.which()
      if type_name == "struct":
        struct_schema = field.schema.elementType if base_type == "list" else field.schema
        return capnp_random_dict(fuzzy, struct_schema, real_floats=real_floats)
      if type_name == "list":
        return fuzzy.list(lambda: rec(field_type.list.elementType, "list"))
      if type_name == "enum":
        enum_schema = field.schema.elementType if base_type == "list" else field.schema
        return fuzzy.choice(tuple(enum_schema.enumerants))
      return native(type_name)

    try:
      if hasattr(field.proto, "slot"):
        slot_type = field.proto.slot.type
        return rec(slot_type, slot_type.which())
      return capnp_random_dict(fuzzy, field.schema, real_floats=real_floats)
    except capnp.lib.capnp.KjException:
      return capnp_random_dict(fuzzy, field.schema, real_floats=real_floats)

  union_field = event or (fuzzy.choice(tuple(schema.union_fields)) if schema.union_fields else None)
  fields = schema.non_union_fields + ((union_field,) if union_field else ())
  return {
    field_name: generate_field(schema.fields[field_name])
    for field_name in fields
    if not field_name.endswith("DEPRECATED") and field_name != "deprecated"
  }
