"""Automatic Pydantic configuration generation from function signatures.

This module provides the @configurable decorator that automatically extracts
hyperparameters (marked with Hyper[T]) from function/class signatures and
generates corresponding Pydantic configuration models at runtime.

Key features:
- Automatic extraction of Hyper[T] parameters
- Type-safe constraint validation (Ge, Le, Gt, Lt, etc.)
- Support for functions, methods, classes, and dataclasses
- Thread-safe dynamic class generation
- Nested configuration support via DEFAULT sentinel
"""

from __future__ import annotations

import dataclasses
import inspect
import threading
from contextvars import ContextVar
from typing import (
  TYPE_CHECKING,
  Annotated,
  Any,
  Final,
  ParamSpec,
  TypeVar,
  cast,
  get_args,
  get_origin,
  get_type_hints,
)

from annotated_types import (
  BaseMetadata,
)
from annotated_types import (
  Ge as AtGe,
)
from annotated_types import (
  Gt as AtGt,
)
from annotated_types import (
  Le as AtLe,
)
from annotated_types import (
  Lt as AtLt,
)
from annotated_types import (
  MaxLen as AtMaxLen,
)
from annotated_types import (
  MinLen as AtMinLen,
)
from annotated_types import (
  MultipleOf as AtMultipleOf,
)
from loguru import logger
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo

from hipr.constraints import (
  Ge,
  Gt,
  Le,
  Lt,
  MaxLen,
  MinLen,
  MultipleOf,
  Pattern,
)

if TYPE_CHECKING:
  from collections.abc import Callable


# Define DEFAULT sentinel before __all__ for type checker visibility
class _DefaultSentinel:
  """Sentinel for default nested config values."""

  def __repr__(self) -> str:
    return "DEFAULT"


DEFAULT: Final[_DefaultSentinel] = _DefaultSentinel()

__all__ = [
  "DEFAULT",
  "ConfigurableIndicator",
  "Ge",
  "Gt",
  "Hyper",
  "Le",
  "Lt",
  "MakeableModel",
  "MaxLen",
  "MinLen",
  "MultipleOf",
  "Pattern",
  "configurable",
  "validate_config",
]

# Lock for thread-safe class decoration
_config_creation_lock = threading.Lock()

# Track config creation stack for circular dependency detection
_config_creation_stack: ContextVar[list[str] | None] = ContextVar(
  "_config_creation_stack", default=None
)

# Pattern constraints use tuple format (key, value)
_PATTERN_CONSTRAINT_TUPLE_LENGTH = 2


def _extract_public_fields(model: BaseModel) -> dict[str, object]:
  """Extract non-private fields from a Pydantic model.

  Args:
    model: The Pydantic model instance

  Returns:
    Dictionary of field names to values, excluding private fields (starting with _)
  """
  data = {k: v for k, v in model.__dict__.items() if not k.startswith("_")}

  return data


class _HyperMarker:
  """Internal marker for hyperparameters."""


class Hyper:
  """Type supporting Hyper[T] and Hyper[T, Ge[2], Le[100]] syntax."""

  def __class_getitem__[T](
    cls, args: type[T] | tuple[type[T], *tuple[object, ...]]
  ) -> object:
    """Support Hyper[T] or Hyper[T, Ge[2], Le[100]] syntax.

    Examples:
      period: Hyper[int] = 14  # Simple type
      period: Hyper[int, Ge[2], Le[100]] = 14  # With constraints

    Returns:
      Annotated type with _HyperMarker for runtime detection.
    """
    # Handle simple case: Hyper[int]
    if not isinstance(args, tuple):
      return Annotated[args, _HyperMarker]

    # Handle constraint case: Hyper[int, Ge[2], Le[100]]
    # We just pass everything to Annotated. The extraction logic
    # in _extract_hyper_field_info will handle converting constraints to Field.
    inner_type = args[0]
    constraints = args[1:]
    return Annotated[inner_type, _HyperMarker, *constraints]


P = ParamSpec("P")
R = TypeVar("R")


def _get_type_hints_with_fallback(fn: Callable[..., Any]) -> dict[str, object]:
  """Get type hints with fallback for TYPE_CHECKING imports.

  Args:
    fn: Function or class to extract type hints from

  Returns:
    Dictionary mapping parameter names to their type annotations

  Note:
    When get_type_hints() fails due to NameError (from TYPE_CHECKING imports),
    retries with an augmented namespace containing the Hyper type.
  """
  # Try normal resolution first
  try:
    # Classes don't have __globals__, so use simpler call for them
    if inspect.isclass(fn):
      return get_type_hints(fn, include_extras=True)
    return get_type_hints(fn, globalns=fn.__globals__, include_extras=True)
  except NameError:
    # Fallback: augment namespace with our types
    if inspect.isclass(fn):
      # For classes, get the module's globals
      module = inspect.getmodule(fn)
      augmented_globals = {} if module is None else vars(module).copy()
    else:
      augmented_globals = fn.__globals__.copy()

    # Add Hyper to globals for TYPE_CHECKING imports
    augmented_globals["Hyper"] = Hyper
    return get_type_hints(fn, globalns=augmented_globals, include_extras=True)


class MakeableModel[R](BaseModel):
  """Base class for generated configuration models.

  Generated config models inherit from this class and override the make()
  method to create bound callables with the configured hyperparameters.
  """

  def make(self) -> Callable[..., R]:
    """Creates a callable with hyperparameters bound from this configuration.

    Returns:
      A callable (function or class constructor) with hyperparameters pre-bound

    Raises:
      NotImplementedError: This base implementation should be overridden
    """
    raise NotImplementedError(
      "This method should be overridden in the dynamic subclass."
    )


class ConfigurableIndicator[**P, R]:
  """Wrapper for @configurable decorated functions.

  Holds the original function and its generated Config class. The function
  remains directly callable while exposing the Config class for configuration.

  Attributes:
    Config: Generated Pydantic model class for this function's hyperparameters
  """

  Config: type[MakeableModel[R]]

  def __init__(self, fn: Callable[P, R], config_cls: type[MakeableModel[R]]) -> None:
    super().__init__()
    self._fn = fn
    self.Config = config_cls

  def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
    """Call the wrapped function directly."""
    return self._fn(*args, **kwargs)

  def __get__(
    self, obj: object, objtype: type | None = None
  ) -> ConfigurableIndicator[P, R]:
    """Support method binding via descriptor protocol.

    Returns:
      Unbound ConfigurableIndicator if accessed from class,
      bound ConfigurableIndicator if accessed from instance
    """
    if obj is None:
      return self
    bound_fn = self._fn.__get__(obj, objtype)
    return ConfigurableIndicator(bound_fn, self.Config)


def _extract_dataclass_params(cls: type) -> dict[str, tuple[type, FieldInfo]]:
  """Extract hyperparameters from a dataclass.

  Args:
    cls: Dataclass to extract parameters from

  Returns:
    Dictionary mapping parameter names to (type, FieldInfo) tuples
  """
  hyper_params: dict[str, tuple[type, FieldInfo]] = {}
  type_hints = _get_type_hints_with_fallback(cls)

  for field in dataclasses.fields(cls):
    if field.name not in type_hints:
      continue

    field_type = type_hints[field.name]

    if field.default is not dataclasses.MISSING:
      default_val = field.default
    elif field.default_factory is not dataclasses.MISSING:
      default_val = field.default_factory()
    else:
      default_val = inspect.Parameter.empty

    field_info = _extract_hyper_field_info(field_type, default_val, field.name)
    if field_info:
      hyper_params[field.name] = field_info
  return hyper_params


def _extract_init_params(cls: type) -> dict[str, tuple[type, FieldInfo]]:
  """Extract hyperparameters from class __init__ signature.

  Args:
    cls: Class to extract __init__ parameters from

  Returns:
    Dictionary mapping parameter names to (type, FieldInfo) tuples
  """
  hyper_params: dict[str, tuple[type, FieldInfo]] = {}
  init_fn = cls.__init__
  sig = inspect.signature(init_fn)
  type_hints = _get_type_hints_with_fallback(init_fn)

  for param in sig.parameters.values():
    if param.name == "self":
      continue

    if param.name not in type_hints:
      continue

    annotation = type_hints[param.name]
    default_val = param.default

    field_info = _extract_hyper_field_info(
      annotation, cast("object", default_val), param.name
    )
    if field_info:
      hyper_params[param.name] = field_info
  return hyper_params


def _configurable_class[R](cls: type[R]) -> type[R]:
  """Apply @configurable decorator to a class.

  Extracts Hyper parameters from either dataclass fields or __init__ signature
  and generates a Config class. Uses double-check locking for thread safety.

  Args:
    cls: Class to decorate

  Returns:
    The same class with a Config attribute attached

  Raises:
    ValueError: If 'model_config' is used as a parameter name
  """
  # Fast path: if already decorated, return immediately without acquiring lock
  # Use __dict__ to check own attributes (not inherited)
  if "Config" in cls.__dict__:
    return cls

  # Slow path: acquire lock for decoration
  with _config_creation_lock:
    # Double-check: another thread might have decorated while we waited for lock
    if "Config" in cls.__dict__:
      return cls

    hyper_params: dict[str, tuple[type, FieldInfo]] = {}

    if dataclasses.is_dataclass(cls):
      hyper_params = _extract_dataclass_params(cls)
    elif hasattr(cls, "__init__"):
      hyper_params = _extract_init_params(cls)

    # Log configuration creation
    logger.debug(
      "Creating Config for class '{}' with {} hyperparameter(s): {}",
      cls.__name__,
      len(hyper_params),
      list(hyper_params.keys()),
    )

    # Handle model_config name collision
    if "model_config" in hyper_params:
      msg = (
        "The parameter name 'model_config' is reserved by Pydantic. "
        "Please rename this parameter (e.g., to 'model_cfg' or 'config')."
      )
      raise ValueError(msg)

    # Create the Config class
    def make(self: MakeableModel[R]) -> Callable[..., R]:
      """Creates a bound class constructor with the given hyperparameters.

      Uses a closure for efficient callable creation.
      """
      kwargs = _extract_public_fields(self)

      def bound_constructor(*args: Any, **kw: Any) -> R:  # noqa: ANN401
        return cls(*args, **kwargs, **kw)

      return bound_constructor

    # Dynamically create Pydantic model from extracted hyperparameters
    config_model = cast(
      type[MakeableModel[R]],
      create_model(
        f"{cls.__name__}Config",
        __base__=MakeableModel[R],
        **hyper_params,  # type: ignore[arg-type]
      ),
    )
    config_model.make = make

    # Dynamically attach Config class to the decorated class
    cls.Config = config_model  # type: ignore[attr-defined]

    return cls


def _extract_annotated_constraint(item: BaseMetadata) -> dict[str, object]:
  """Extract constraint from an annotated_types metadata object.

  Args:
    item: annotated_types constraint object (Ge, Le, Gt, Lt, etc.)

  Returns:
    Dictionary with constraint field names and values for Pydantic Field
  """
  constraints: dict[str, object] = {}
  if isinstance(item, AtGe):
    constraints["ge"] = item.ge
  elif isinstance(item, AtLe):
    constraints["le"] = item.le
  elif isinstance(item, AtGt):
    constraints["gt"] = item.gt
  elif isinstance(item, AtLt):
    constraints["lt"] = item.lt
  elif isinstance(item, AtMinLen):
    constraints["min_length"] = item.min_length
  elif isinstance(item, AtMaxLen):
    constraints["max_length"] = item.max_length
  elif isinstance(item, AtMultipleOf):
    constraints["multiple_of"] = item.multiple_of
  return constraints


def _validate_constraint_conflicts(
  constraints: dict[str, object], param_name: str
) -> None:
  """Validate that constraints don't conflict with each other.

  Raises:
    ValueError: If constraints are contradictory (e.g., Ge[10] and Lt[10])
  """
  # Check numeric bound conflicts
  ge_val = constraints.get("ge")
  gt_val = constraints.get("gt")
  le_val = constraints.get("le")
  lt_val = constraints.get("lt")

  # Determine effective lower and upper bounds
  lower_bound: float | int | None = None
  lower_inclusive = True
  if ge_val is not None and isinstance(ge_val, (int, float)):
    lower_bound = ge_val
    lower_inclusive = True
  if (
    gt_val is not None
    and isinstance(gt_val, (int, float))
    and (lower_bound is None or gt_val >= lower_bound)
  ):
    lower_bound = gt_val
    lower_inclusive = False

  upper_bound: float | int | None = None
  upper_inclusive = True
  if le_val is not None and isinstance(le_val, (int, float)):
    upper_bound = le_val
    upper_inclusive = True
  if (
    lt_val is not None
    and isinstance(lt_val, (int, float))
    and (upper_bound is None or lt_val <= upper_bound)
  ):
    upper_bound = lt_val
    upper_inclusive = False

  # Check if bounds are impossible
  if lower_bound is not None and upper_bound is not None:
    if lower_bound > upper_bound:
      msg = (
        f"Parameter '{param_name}' has contradictory constraints: "
        f"lower bound ({lower_bound}) is greater than upper bound ({upper_bound})"
      )
      raise ValueError(msg)
    if lower_bound == upper_bound and not (lower_inclusive and upper_inclusive):
      msg = (
        f"Parameter '{param_name}' has contradictory constraints: "
        f"no value can satisfy both strict and non-strict bounds at {lower_bound}"
      )
      raise ValueError(msg)

  # Check length constraint conflicts
  min_len = constraints.get("min_length")
  max_len = constraints.get("max_length")

  if (
    min_len is not None
    and max_len is not None
    and isinstance(min_len, int)
    and isinstance(max_len, int)
    and min_len > max_len
  ):
    msg = (
      f"Parameter '{param_name}' has contradictory constraints: "
      f"min_length ({min_len}) is greater than max_length ({max_len})"
    )
    raise ValueError(msg)


def _extract_constraints_from_metadata(
  metadata: tuple[object, ...],
) -> dict[str, object]:
  """Extract constraints from Annotated metadata.

  Args:
    metadata: Tuple of metadata items from Annotated type

  Returns:
    Dictionary of constraint field names to values for Pydantic Field

  Note:
    Handles both annotated_types objects (Ge, Le, etc.) and tuple format
    used by Pattern constraints.
  """
  constraints: dict[str, object] = {}
  for item in metadata:
    if isinstance(item, BaseMetadata):
      constraints.update(_extract_annotated_constraint(item))
    elif isinstance(item, tuple) and len(item) == _PATTERN_CONSTRAINT_TUPLE_LENGTH:
      # Handle tuple constraints (Pattern uses this format)
      key, value = cast(tuple[str, object], item)
      if isinstance(key, str):
        constraints[key] = value

  return constraints


def _extract_hyper_field_info(
  annotation: object, default_val: object, param_name: str
) -> tuple[type, FieldInfo] | None:
  """Extract field information from a Hyper annotation.

  Args:
    annotation: Type annotation to analyze
    default_val: Default value for the parameter
    param_name: Name of the parameter (for error messages)

  Returns:
    Tuple of (inner_type, FieldInfo) if annotation is Hyper[T], None otherwise

  Raises:
    TypeError: If Hyper parameter lacks default value or DEFAULT is misused
  """
  # Check for Annotated[T, _HyperMarker] pattern
  if get_origin(annotation) is not Annotated:
    return None

  args = get_args(annotation)
  if not args:
    return None

  # args[0] is the type, args[1:] are metadata
  inner_type = cast("type", args[0])
  metadata = cast("tuple[object, ...]", args[1:])

  # Check for _HyperMarker
  has_hyper_marker = any(
    isinstance(arg, type) and issubclass(arg, _HyperMarker) for arg in metadata
  )
  if not has_hyper_marker:
    return None

  if default_val is inspect.Parameter.empty:
    msg = f"Hyperparameter '{param_name}' must have a default value."
    raise TypeError(msg)

  # Handle DEFAULT sentinel for nested configs
  if isinstance(default_val, _DefaultSentinel):
    # Check if inner_type is a MakeableModel subclass
    if inspect.isclass(inner_type) and issubclass(inner_type, MakeableModel):
      # Check for circular dependency
      stack = _config_creation_stack.get() or []
      config_name = inner_type.__name__

      if config_name in stack:
        cycle_path = " -> ".join([*stack, config_name])
        msg = (
          f"Circular dependency detected: {cycle_path}. "
          f"Parameter '{param_name}' creates a cycle."
        )
        raise ValueError(msg)

      # Track this config creation
      new_stack = [*stack, config_name]
      token = _config_creation_stack.set(new_stack)

      try:
        # Validate that the config has a proper make method
        instance = inner_type()
        if not hasattr(instance, "make") or not callable(instance.make):
          msg = (
            f"Nested config type '{inner_type.__name__}' for parameter "
            f"'{param_name}' does not have a callable 'make' method"
          )
          raise TypeError(msg)
        default_val = instance
      except ValueError:
        # Re-raise circular dependency errors without wrapping
        raise
      except Exception as e:
        msg = (
          f"Failed to instantiate nested config type '{inner_type.__name__}' "
          f"for parameter '{param_name}': {e}"
        )
        raise TypeError(msg) from e
      finally:
        _config_creation_stack.reset(token)
    else:
      msg = (
        f"DEFAULT can only be used with nested Config types, "
        f"but '{param_name}' has type '{inner_type.__name__}'"
      )
      raise TypeError(msg)

  # Extract constraints from metadata
  constraints = _extract_constraints_from_metadata(metadata)

  # Validate constraints for conflicts (early error detection)
  _validate_constraint_conflicts(constraints, param_name)

  if constraints:
    logger.debug(
      "Parameter '{}' has constraints: {}",
      param_name,
      constraints,
    )

  # Check if there's an existing FieldInfo in the metadata
  existing_field_info = next(
    (arg for arg in metadata if isinstance(arg, FieldInfo)),
    None,
  )

  if existing_field_info:
    return (inner_type, existing_field_info)

  # Create Field with constraints
  return (
    inner_type,
    Field(default=default_val, **cast("Any", constraints)),
  )


def configurable[**P, R](
  fn_or_class: Callable[P, R] | type[R],
) -> ConfigurableIndicator[P, R] | type[R]:
  """Decorator that generates Pydantic config models from Hyper parameters.

  Scans the decorated function/class for parameters annotated with Hyper[T]
  and automatically generates a Config class that can validate and bind these
  hyperparameters.

  Args:
    fn_or_class: Function, method, or class to decorate

  Returns:
    ConfigurableIndicator for functions/methods, or the original class with
    Config attribute attached for classes

  Raises:
    ValueError: If 'model_config' is used as a parameter name
    TypeError: If Hyper parameter lacks a default value

  Example:
    >>> @configurable
    ... def process(data: pd.DataFrame, window: Hyper[int, Ge[1]] = 14) -> float:
    ...     return data.rolling(window).mean().sum()
    >>> config = process.Config(window=7)
    >>> fn = config.make()
    >>> result = fn(my_data)
  """
  # Check if this is a class
  if inspect.isclass(fn_or_class):
    return _configurable_class(fn_or_class)

  fn = fn_or_class  # It's a function/method
  hyper_params = {}

  # Get raw annotations first (without evaluation)
  sig = inspect.signature(fn)

  # Check if this is a method
  params_list = list(sig.parameters.values())
  is_method = params_list and params_list[0].name in {"self", "cls"}

  # Get type hints with fallback for TYPE_CHECKING imports
  type_hints = _get_type_hints_with_fallback(fn)

  for param in sig.parameters.values():
    # Skip self/cls parameters for methods
    if is_method and param.name in {"self", "cls"}:
      continue

    if param.name not in type_hints:
      continue

    annotation = type_hints[param.name]
    default_val = param.default

    field_info = _extract_hyper_field_info(
      annotation, cast("object", default_val), param.name
    )
    if field_info:
      hyper_params[param.name] = field_info

  # Log configuration creation
  logger.debug(
    "Creating Config for '{}' with {} hyperparameter(s): {}",
    fn.__name__,
    len(hyper_params),
    list(hyper_params.keys()),
  )

  # Handle model_config name collision
  if "model_config" in hyper_params:
    msg = (
      "The parameter name 'model_config' is reserved by Pydantic. "
      "Please rename this parameter (e.g., to 'model_cfg' or 'config')."
    )
    raise ValueError(msg)

  def make(self: MakeableModel[R]) -> Callable[P, R]:
    """Creates a bound function with the given hyperparameters.

    Uses a closure for efficient callable creation.
    """
    kwargs = _extract_public_fields(self)

    def bound_fn(*args: P.args, **kw: P.kwargs) -> R:
      return fn(*args, **kwargs, **kw)

    return bound_fn

  # Dynamically create Pydantic model from extracted hyperparameters
  config_model = create_model(
    f"{fn.__name__.capitalize()}Config",
    __base__=MakeableModel[R],
    **hyper_params,  # type: ignore[arg-type]
  )
  config_model.make = make

  # Attach Config directly to the function to avoid wrapper overhead
  fn.Config = config_model  # type: ignore[attr-defined]
  return fn  # type: ignore[return-value]


def validate_config[R](
  config_cls: type[MakeableModel[R]], data: dict[str, object]
) -> tuple[bool, list[str]]:
  """Validate config data without creating an instance.

  Args:
    config_cls: The Config class to validate against
    data: Dictionary of field values to validate

  Returns:
    Tuple of (is_valid, error_messages)

  Example:
    >>> @configurable
    ... def func(x: Hyper[int, Ge[0]] = 10) -> int:
    ...     return x
    >>> valid, errors = validate_config(func.Config, {"x": 5})
    >>> assert valid
    >>> valid, errors = validate_config(func.Config, {"x": -1})
    >>> assert not valid
    >>> assert len(errors) > 0
  """
  from pydantic import ValidationError

  try:
    config_cls(**data)
    return (True, [])
  except ValidationError as e:
    errors = [str(err) for err in e.errors()]
    return (False, errors)
