"""Utilities for the MADSci project."""

import functools
import json
import logging
import random
import re
import sys
import threading
import time
import warnings
from argparse import ArgumentTypeError
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, get_args, get_origin

from pydantic import ValidationError
from pydantic_core._pydantic_core import PydanticUndefined
from rich.console import Console
from ulid import ULID

console = Console()
logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    import requests
    from madsci.common.types.base_types import MadsciBaseModel, PathLike
    from madsci.common.types.client_types import MadsciClientConfig


def utcnow() -> datetime:
    """Return the current UTC time."""

    return datetime.now(timezone.utc)


def localnow() -> datetime:
    """Return the current local time."""

    return datetime.now().astimezone()


def to_snake_case(name: str) -> str:
    """Convert a string to snake case.

    Handles conversion from camelCase and PascalCase to snake_case.
    """
    name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
    name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name)
    return name.lower().replace(" ", "_").replace("__", "_")


def search_for_file_pattern(
    pattern: str,
    start_dir: Optional["PathLike"] = None,
    parents: bool = True,
    children: bool = True,
) -> list[str]:
    """
    Search up and down the file tree for a file(s) matching a pattern.

    Args:
        pattern: The pattern to search for. Standard glob patterns are supported.
        start_dir: The directory to start the search in. Defaults to the current directory.
        parents: Whether to search in parent directories.
        children: Whether to search in subdirectories.

    Returns:
        A list of paths to the files that match the pattern.
    """

    start_dir = Path.cwd() if not start_dir else Path(start_dir).expanduser()

    results = []
    if children:
        results.extend(Path("./").glob(str(Path("**") / pattern)))
    else:
        results.extend(Path("./").glob(pattern))
    if parents:
        for parent in start_dir.parents:
            results.extend(Path(parent).glob(pattern))
    return results


def save_model(
    path: "PathLike", model: "MadsciBaseModel", overwrite_check: bool = True
) -> None:
    """Save a MADSci model to a YAML file, optionally with a check to overwrite if the file already exists."""
    try:
        model.model_validate(model)
    except ValidationError as e:
        raise ValueError(f"Validation error while saving model {model}: {e}") from e
    if (
        Path(path).exists()
        and overwrite_check
        and not prompt_yes_no(f"File already exists: {path}. Overwrite?", default="no")
    ):
        return
    model.to_yaml(path)


def prompt_yes_no(prompt: str, default: str = "no", quiet: bool = False) -> bool:
    """Prompt the user for a yes or no answer."""
    response = str(
        prompt_for_input(
            rf"{prompt} \[y/n]",
            default=default,
            required=False,
            quiet=quiet,
        ),
    ).lower()
    return response in ["y", "yes", "true"]


def prompt_for_input(
    prompt: str,
    default: Optional[str] = None,
    required: bool = False,
    quiet: bool = False,
) -> str:
    """Prompt the user for input."""
    if quiet or not sys.stdin.isatty():
        if default:
            return default
        if required:
            raise ValueError(
                "No input provided and no default value specified for required option.",
            )
        return None
    if not required:
        if default:
            response = console.input(f"{prompt} (optional, default: {default}): ")
        else:
            response = console.input(f"{prompt} (optional): ")
        if not response:
            response = default
    else:
        response = None
        while not response:
            if default:
                response = console.input(f"{prompt} (required, default: {default}): ")
                if not response:
                    response = default
            else:
                response = console.input(f"{prompt} (required): ")
    return response


def new_name_str(prefix: str = "") -> str:
    """Generate a new random name string, optionally with a prefix. Make a random combination of an adjective and a noun. Names are not guaranteed to be unique."""
    adjectives = [
        "happy",
        "clever",
        "bright",
        "swift",
        "calm",
        "bold",
        "eager",
        "fair",
        "kind",
        "proud",
        "brave",
        "wise",
        "quick",
        "sharp",
        "warm",
        "cool",
        "fresh",
        "keen",
        "agile",
        "gentle",
        "noble",
        "merry",
        "lively",
        "grand",
        "smart",
        "witty",
        "jolly",
        "mighty",
        "steady",
        "pure",
        "swift",
        "deft",
        "sage",
        "fleet",
        "spry",
        "bold",
    ]
    nouns = [
        "fox",
        "owl",
        "bear",
        "wolf",
        "hawk",
        "deer",
        "lion",
        "tiger",
        "eagle",
        "whale",
        "seal",
        "dove",
        "swan",
        "crow",
        "duck",
        "horse",
        "mouse",
        "cat",
        "lynx",
        "puma",
        "otter",
        "hare",
        "raven",
        "crane",
        "falcon",
        "badger",
        "marten",
        "stoat",
        "weasel",
        "vole",
        "rabbit",
        "squirrel",
        "raccoon",
        "beaver",
        "moose",
        "elk",
    ]

    name = f"{random.choice(adjectives)}_{random.choice(nouns)}"
    if prefix:
        name = f"{prefix}_{name}"
    return name


def string_to_bool(string: str) -> bool:
    """Convert a string to a boolean value."""
    if string.lower() in ("true", "t", "1", "yes", "y"):
        return True
    if string.lower() in ("false", "f", "0", "no", "n"):
        return False
    raise ArgumentTypeError(f"Invalid boolean value: {string}")


def prompt_from_list(
    prompt: str,
    options: list[str],
    default: Optional[str] = None,
    required: bool = False,
    quiet: bool = False,
) -> str:
    """Prompt the user for input from a list of options."""

    # *Print numbered list of options
    if not quiet:
        for i, option in enumerate(options, 1):
            console.print(f"[bold]{i}[/]. {option}")

    # *Allow selection by number or exact match
    def validate_response(response: str) -> Optional[str]:
        if response in options:
            return response
        try:
            idx = int(response)
            if 1 <= idx <= len(options):
                return options[idx - 1]
        except ValueError:
            pass
        return None

    while True:
        try:
            response = validate_response(
                prompt_for_input(
                    prompt,
                    default=default,
                    required=required,
                    quiet=quiet,
                ),
            )
        except ValueError:
            continue
        else:
            break
    return response


def prompt_from_pydantic_model(
    model: "MadsciBaseModel", prompt: str, **kwargs: Any
) -> str:
    """Prompt the user for input from a pydantic model.

    Args:
        model: The pydantic model to prompt for
        prompt: The prompt to display
        **kwargs: Pre-filled values to skip prompting for

    Returns:
        A dictionary of field values for the model
    """
    result = {}

    # Print header for the prompts
    console.print(f"\n[bold]{prompt}[/]")

    for field_name, field in model.__pydantic_fields__.items():
        # Skip if value provided in kwargs
        if field_name in kwargs:
            result[field_name] = kwargs[field_name]
            continue

        # Build field prompt
        field_prompt = f"{field.title or field_name}"

        # Add type hint
        type_hint = str(field.annotation).replace("typing.", "")
        field_prompt += f" ({type_hint})"

        # Add description if available
        if field.description:
            field_prompt += f"\n{field.description}"

        # Handle basic fields
        while True:
            try:
                response = prompt_for_input(
                    field_prompt,
                    default=field.default
                    if field.default != PydanticUndefined
                    else None,
                    required=field.is_required,
                )
                if isinstance(response, str):
                    response = json.loads(response)
                result[field_name] = response
            except json.JSONDecodeError as e:
                console.print(
                    f"[bold red]Invalid JSON input for field {field_name}: {e}[/]",
                )
                continue
            else:
                break

    return result


def relative_path(source: Path, target: Path, walk_up: bool = True) -> Path:
    """
    "Backport" of :meth:`pathlib.Path.relative_to` with ``walk_up=True``
    that's not available pre 3.12.

    Return the relative path to another path identified by the passed
    arguments.  If the operation is not possible (because this is not
    related to the other path), raise ValueError.

    The *walk_up* parameter controls whether `..` may be used to resolve
    the path.

    References:
        https://github.com/python/cpython/blob/8a2baedc4bcb606da937e4e066b4b3a18961cace/Lib/pathlib/_abc.py#L244-L270
    Credit: https://github.com/p2p-ld/numpydantic/blob/66fffc49f87bfaaa2f4d05bf1730c343b10c9cc6/src/numpydantic/serialization.py#L107
    """
    if not isinstance(source, Path):
        source = Path(source)
    target_parts = target.parts
    source_parts = source.parts
    anchor0, parts0 = target_parts[0], list(reversed(target_parts[1:]))
    anchor1, parts1 = source_parts[0], list(reversed(source_parts[1:]))
    if anchor0 != anchor1:
        raise ValueError(f"{target!r} and {source!r} have different anchors")
    while parts0 and parts1 and parts0[-1] == parts1[-1]:
        parts0.pop()
        parts1.pop()
    for part in parts1:
        if not part or part == ".":
            pass
        elif not walk_up:
            raise ValueError(f"{target!r} is not in the subpath of {source!r}")
        elif part == "..":
            raise ValueError(f"'..' segment in {source!r} cannot be walked")
        else:
            parts0.append("..")
    return Path(*reversed(parts0))


def threaded_task(func: callable) -> callable:
    """Mark a function as a threaded task, to be run without awaiting. Returns the thread object, so you _can_ await if needed."""

    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> threading.Thread:
        thread = threading.Thread(target=func, args=args, kwargs=kwargs)
        thread.start()
        return thread

    return wrapper


def threaded_daemon(func: callable) -> callable:
    """Mark a function as a threaded daemon, to be run without awaiting. Returns the thread object, so you _can_ await if needed, and stops when the calling thread terminates."""

    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> threading.Thread:
        thread = threading.Thread(target=func, args=args, kwargs=kwargs)
        thread.daemon = True
        thread.start()
        return thread

    return wrapper


def pretty_type_repr(type_hint: Any) -> str:
    """Returns a pretty string representation of a type hint, including subtypes."""
    type_name = None
    try:
        type_name = getattr(type_hint, "__name__", None)
        if type_name is None:
            type_origin = get_origin(type_hint)
            type_name = (
                getattr(type_origin, "__name__", None)
                or getattr(type_origin, "__qualname__", None)
                or str(type_hint)
            )
        if (
            "__args__" in dir(type_hint) and type_hint.__args__
        ):  # * If the type has subtype info
            type_name += "["
            for subtype in type_hint.__args__:
                type_name += pretty_type_repr(subtype)
                type_name += ", "
            type_name = type_name[:-2]
            type_name += "]"
        return type_name
    except Exception:
        warnings.warn(
            f"Failed to get pretty type representation for {type_hint}. Returning raw type.",
            stacklevel=2,
        )
        return type_name


@threaded_daemon
def repeat_on_interval(
    interval: float, func: callable, *args: Any, **kwargs: Any
) -> None:
    """Repeat a function on an interval."""

    while True:
        func(*args, **kwargs)
        time.sleep(interval)


def is_optional(type_hint: Any) -> bool:
    """Check if a type hint is Optional."""
    return get_origin(type_hint) is Union and type(None) in get_args(type_hint)


def is_annotated(type_hint: Any) -> bool:
    """Check if a type hint is an annotated type."""
    return get_origin(type_hint) is Annotated


def new_ulid_str() -> str:
    """
    Generate a new ULID string.
    """
    return str(ULID())


def is_valid_ulid(value: str) -> bool:
    """Check if a string is a valid ULID.

    Args:
        value: String to validate

    Returns:
        True if the string is a valid ULID format
    """
    if not isinstance(value, str) or len(value) != 26:
        return False
    # ULID uses Crockford's Base32: 0-9, A-Z (excluding I, L, O, U)
    allowed_chars = set("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
    return all(c.upper() in allowed_chars for c in value)


def extract_datapoint_ids(data: Any) -> list[str]:
    """Extract all datapoint IDs from a data structure.

    Recursively searches through dictionaries, lists, and objects to find
    datapoint IDs (ULID strings that are likely datapoints).

    Args:
        data: Data structure to search

    Returns:
        List of unique datapoint IDs found
    """
    ids = set()

    def _extract_recursive(obj: Any) -> None:
        if isinstance(obj, str) and is_valid_ulid(obj):
            ids.add(obj)
        elif isinstance(obj, dict):
            for value in obj.values():
                _extract_recursive(value)
        elif isinstance(obj, (list, tuple)):
            for item in obj:
                _extract_recursive(item)
        elif hasattr(obj, "datapoint_id"):
            ids.add(obj.datapoint_id)

    _extract_recursive(data)
    return list(ids)


class RateLimitTracker:
    """
    Track rate limit state from HTTP response headers.

    This class maintains rate limit information from server responses and provides
    utilities for logging warnings and determining if requests should be delayed.

    Attributes:
        limit: Maximum number of requests allowed in the time window
        remaining: Number of requests remaining in the current window
        reset: Unix timestamp when the rate limit resets
        burst_limit: Maximum number of requests allowed in short burst window (optional)
        burst_remaining: Number of burst requests remaining (optional)
        warning_threshold: Fraction of limit at which to warn (0.0 to 1.0)
        respect_limits: Whether to enforce delays when approaching limits
    """

    def __init__(
        self,
        warning_threshold: float = 0.8,
        respect_limits: bool = False,
    ) -> None:
        """
        Initialize the rate limit tracker.

        Args:
            warning_threshold: Threshold (0.0 to 1.0) at which to log warnings
            respect_limits: Whether to enforce delays when approaching limits
        """
        self.limit: Optional[int] = None
        self.remaining: Optional[int] = None
        self.reset: Optional[int] = None
        self.burst_limit: Optional[int] = None
        self.burst_remaining: Optional[int] = None
        self.warning_threshold = warning_threshold
        self.respect_limits = respect_limits
        self._lock = threading.Lock()

    def update_from_headers(self, headers: dict[str, str]) -> None:
        """
        Update rate limit state from response headers.

        Args:
            headers: HTTP response headers dictionary
        """
        with self._lock:
            # Parse long window rate limit headers
            if "X-RateLimit-Limit" in headers:
                self.limit = int(headers["X-RateLimit-Limit"])
            if "X-RateLimit-Remaining" in headers:
                self.remaining = int(headers["X-RateLimit-Remaining"])
            if "X-RateLimit-Reset" in headers:
                self.reset = int(headers["X-RateLimit-Reset"])

            # Parse burst window rate limit headers if present
            if "X-RateLimit-Burst-Limit" in headers:
                self.burst_limit = int(headers["X-RateLimit-Burst-Limit"])
            if "X-RateLimit-Burst-Remaining" in headers:
                self.burst_remaining = int(headers["X-RateLimit-Burst-Remaining"])

            # Log warnings if approaching limits
            self._check_and_warn()

    def _check_and_warn(self) -> None:
        """Check if we're approaching rate limits and log warnings."""
        if self.limit is not None and self.remaining is not None:
            usage_fraction = 1.0 - (self.remaining / self.limit)
            if usage_fraction >= self.warning_threshold:
                resets_at = (
                    datetime.fromtimestamp(self.reset, tz=timezone.utc)
                    if self.reset
                    else None
                )
                logger.warning(
                    "Approaching rate limit: %s/%s remaining (resets at %s)",
                    self.remaining,
                    self.limit,
                    resets_at or "unknown",
                )

        if self.burst_limit is not None and self.burst_remaining is not None:
            burst_usage_fraction = 1.0 - (self.burst_remaining / self.burst_limit)
            if burst_usage_fraction >= self.warning_threshold:
                logger.warning(
                    "Approaching burst rate limit: %s/%s remaining",
                    self.burst_remaining,
                    self.burst_limit,
                )

    def get_delay_seconds(self) -> float:
        """
        Calculate delay in seconds before next request if respect_limits is enabled.

        Returns:
            Number of seconds to delay, or 0.0 if no delay needed
        """
        if not self.respect_limits:
            return 0.0

        with self._lock:
            # Check if we've exhausted our limits
            if (
                self.remaining is not None
                and self.remaining <= 0
                and self.reset is not None
            ):
                delay = max(0.0, self.reset - time.time())
                logger.info(
                    "Rate limit exhausted, delaying %.2f seconds before next request",
                    delay,
                )
                return delay

            if self.burst_remaining is not None and self.burst_remaining <= 0:
                # For burst limits, wait 1 second for the window to slide
                logger.info("Burst rate limit exhausted, delaying 1 second")
                return 1.0

        return 0.0

    def get_status(self) -> dict[str, Any]:
        """
        Get current rate limit status.

        Returns:
            Dictionary with current rate limit information
        """
        with self._lock:
            return {
                "limit": self.limit,
                "remaining": self.remaining,
                "reset": self.reset,
                "reset_datetime": (
                    datetime.fromtimestamp(self.reset, tz=timezone.utc).isoformat()
                    if self.reset
                    else None
                ),
                "burst_limit": self.burst_limit,
                "burst_remaining": self.burst_remaining,
            }


class RateLimitHTTPAdapter:
    """
    HTTP adapter that handles rate limit headers and delays.

    This adapter extends HTTPAdapter to add rate limit tracking and
    automatic delay enforcement when approaching or exceeding rate limits.
    """

    def __init__(
        self,
        rate_limit_tracker: RateLimitTracker,
        *args: Any,
        **kwargs: Any,
    ) -> None:
        """
        Initialize the rate limit adapter.

        Args:
            rate_limit_tracker: Tracker to update with rate limit information
            *args: Positional arguments for HTTPAdapter
            **kwargs: Keyword arguments for HTTPAdapter
        """
        from requests.adapters import HTTPAdapter  # noqa: PLC0415

        # Create a real HTTPAdapter instance that we'll delegate to
        self._adapter = HTTPAdapter(*args, **kwargs)
        self.rate_limit_tracker = rate_limit_tracker

    def send(self, request: Any, **kwargs: Any) -> Any:
        """
        Send a request with rate limit checking and tracking.

        Args:
            request: The request to send
            **kwargs: Additional arguments for the adapter

        Returns:
            The response from the adapter
        """
        # Check if we should delay before sending
        delay = self.rate_limit_tracker.get_delay_seconds()
        if delay > 0:
            time.sleep(delay)

        # Send the request using the underlying adapter
        response = self._adapter.send(request, **kwargs)

        # Update rate limit tracking from response headers
        self.rate_limit_tracker.update_from_headers(dict(response.headers))

        return response

    def close(self) -> None:
        """Close the underlying adapter."""
        self._adapter.close()

    def __getattr__(self, name: str) -> Any:
        """Delegate all other attributes to the underlying adapter."""
        return getattr(self._adapter, name)


def create_http_session(
    config: Optional["MadsciClientConfig"] = None,
    retry_enabled: Optional[bool] = None,
) -> "requests.Session":
    """
    Create a requests.Session with standardized configuration.

    This function creates a properly configured requests session with retry
    strategies, timeout defaults, connection pooling, and rate limit tracking
    based on the provided client configuration. This ensures consistency across
    all MADSci HTTP clients.

    The session includes rate limit tracking if enabled in the config. Rate limit
    information is tracked from X-RateLimit-* headers and can be accessed via
    the session.rate_limit_tracker attribute.

    Args:
        config: Client configuration object. If None, uses default MadsciClientConfig.
        retry_enabled: Override for retry_enabled from config. If None, uses config value.

    Returns:
        Configured requests.Session object with optional rate_limit_tracker attribute

    Example:
        >>> from madsci.common.types.client_types import MadsciClientConfig
        >>> from madsci.common.utils import create_http_session
        >>>
        >>> # Use default configuration
        >>> session = create_http_session()
        >>>
        >>> # Use custom configuration
        >>> config = MadsciClientConfig(retry_total=5, timeout_default=30.0)
        >>> session = create_http_session(config=config)
        >>>
        >>> # Disable retry for a specific session
        >>> session_no_retry = create_http_session(config=config, retry_enabled=False)
        >>>
        >>> # Check rate limit status
        >>> if hasattr(session, 'rate_limit_tracker'):
        ...     status = session.rate_limit_tracker.get_status()
        ...     print(f"Remaining requests: {status['remaining']}/{status['limit']}")
    """
    # Import here to avoid circular dependencies
    import requests  # noqa: PLC0415
    from madsci.common.types.client_types import MadsciClientConfig  # noqa: PLC0415
    from requests.adapters import HTTPAdapter  # noqa: PLC0415
    from urllib3.util.retry import Retry  # noqa: PLC0415

    # Use default config if none provided
    if config is None:
        config = MadsciClientConfig()

    # Determine if retry should be enabled
    enable_retry = retry_enabled if retry_enabled is not None else config.retry_enabled

    # Create the session
    session = requests.Session()

    # Create rate limit tracker if enabled
    # Check if config has rate limit fields (only MadsciClientConfig has them)
    rate_limit_tracker = None
    if getattr(config, "rate_limit_tracking_enabled", False):
        rate_limit_tracker = RateLimitTracker(
            warning_threshold=getattr(config, "rate_limit_warning_threshold", 0.8),
            respect_limits=getattr(config, "rate_limit_respect_limits", False),
        )
        # Attach tracker to session for user access
        session.rate_limit_tracker = rate_limit_tracker  # type: ignore[attr-defined]

    # Configure retry strategy if enabled
    if enable_retry:
        retry_kwargs = {
            "total": config.retry_total,
            "status_forcelist": config.retry_status_forcelist,
            "backoff_factor": config.retry_backoff_factor,
        }

        # Only add allowed_methods if specified (None means use urllib3 defaults)
        if config.retry_allowed_methods is not None:
            retry_kwargs["allowed_methods"] = config.retry_allowed_methods

        retry_strategy = Retry(**retry_kwargs)

        # Create adapter with retry strategy and connection pooling
        if rate_limit_tracker:
            # Use rate limit aware adapter
            adapter = RateLimitHTTPAdapter(
                rate_limit_tracker=rate_limit_tracker,
                max_retries=retry_strategy,
                pool_connections=config.pool_connections,
                pool_maxsize=config.pool_maxsize,
            )
        else:
            # Use standard adapter
            adapter = HTTPAdapter(
                max_retries=retry_strategy,
                pool_connections=config.pool_connections,
                pool_maxsize=config.pool_maxsize,
            )

        # Mount adapter for both http and https
        session.mount("http://", adapter)
        session.mount("https://", adapter)
    else:
        # Even without retry, configure connection pooling
        if rate_limit_tracker:
            # Use rate limit aware adapter
            adapter = RateLimitHTTPAdapter(
                rate_limit_tracker=rate_limit_tracker,
                pool_connections=config.pool_connections,
                pool_maxsize=config.pool_maxsize,
            )
        else:
            # Use standard adapter
            adapter = HTTPAdapter(
                pool_connections=config.pool_connections,
                pool_maxsize=config.pool_maxsize,
            )
        session.mount("http://", adapter)
        session.mount("https://", adapter)

    return session
