"""Primitive data structures."""

from __future__ import annotations

import asyncio
import bisect
import collections
from collections.abc import Coroutine
import contextlib
from fractions import Fraction
import functools
import heapq
import logging
import math
import types
import typing
from typing import Any, Generic, Self, TypeVar
import warnings

_LOGGER = logging.getLogger(__name__)

_T = TypeVar("_T", bound=contextlib.AbstractAsyncContextManager)


class WrappedContextManager(Generic[_T]):
    def __init__(
        self,
        context_manager: _T,
        on_enter: typing.Callable[[], Coroutine[Any, Any, Any]],
    ) -> None:
        self.on_enter = on_enter
        self.context_manager = context_manager

    async def __aenter__(self) -> _T:
        await self.on_enter()
        return self.context_manager

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        traceback: types.TracebackType | None,
    ) -> None:
        await self.context_manager.__aexit__(exc_type, exc, traceback)


class PriorityDynamicBoundedSemaphore:
    """`asyncio.BoundedSemaphore` with public interface to change the max value."""

    def __init__(self, value: int = 0) -> None:
        self._value: int = value
        self._max_value: int = value
        self._comparison_counter: int = 0
        self._waiters: list[tuple[int, int, asyncio.Future]] = []
        self._loop: asyncio.AbstractEventLoop | None = None

    def _get_loop(self) -> asyncio.AbstractEventLoop:
        loop = asyncio.get_running_loop()

        if self._loop is None:
            self._loop = loop

        if loop is not self._loop:
            raise RuntimeError(f"{self!r} is bound to a different event loop")

        return loop

    def _wake_up_next(self) -> bool:
        """Wake up the first waiter that isn't done."""
        if not self._waiters:
            return False

        for _, _, fut in self._waiters:
            if not fut.done():
                self._value -= 1
                fut.set_result(True)
                # `fut` is now `done()` and not `cancelled()`.
                return True
        return False

    def cancel_waiting(self, exc: BaseException) -> None:
        """Cancel all waiters with the given exception."""
        for _, _, fut in self._waiters:
            if not fut.done():
                fut.set_exception(exc)

    @property
    def value(self) -> int:
        return self._value

    @property
    def max_value(self) -> int:
        return self._max_value

    @max_value.setter
    def max_value(self, new_value: int) -> None:
        """Update the semaphore's max value."""
        if new_value < 0:
            raise ValueError(f"Semaphore value must be >= 0: {new_value!r}")

        delta = new_value - self._max_value
        self._value += delta
        self._max_value += delta

        # Wake up any pending waiters
        for _ in range(max(0, delta)):
            if not self._wake_up_next():
                break

    @property
    def num_waiting(self) -> int:
        return len(self._waiters)

    def locked(self) -> bool:
        """Returns True if semaphore cannot be acquired immediately."""
        # Due to state, or FIFO rules (must allow others to run first).
        return self._value <= 0 or (any(not w.cancelled() for _, _, w in self._waiters))

    async def acquire(self, priority: int = 0) -> typing.Literal[True]:
        """Acquire a semaphore.

        If the internal counter is larger than zero on entry,
        decrement it by one and return True immediately.  If it is
        zero on entry, block, waiting until some other task has
        called release() to make it larger than 0, and then return
        True.
        """
        if not self.locked():
            # Maintain FIFO, wait for others to start even if _value > 0.
            self._value -= 1
            return True

        # To ensure that our objects don't have to be themselves comparable, we
        # maintain a global count and increment it on every insert. This way,
        # the tuple `(-priority, count, item)` will never have to compare `item`.
        self._comparison_counter += 1

        fut = self._get_loop().create_future()
        obj = (-priority, self._comparison_counter, fut)
        bisect.insort_right(self._waiters, obj)

        try:
            try:
                await fut
            finally:
                self._waiters.remove(obj)
        except asyncio.CancelledError:
            # Only undo the bookkeeping if the slot was actually granted. If the future
            # instead carries an exception (e.g. from `cancel_waiting`), no slot was
            # ever acquired and there is nothing to release.
            if fut.done() and not fut.cancelled() and fut.exception() is None:
                # Our Future was successfully set to True via _wake_up_next(),
                # but we are not about to successfully acquire(). Therefore we
                # must undo the bookkeeping already done and attempt to wake
                # up someone else.
                self._value += 1
            raise

        finally:
            # New waiters may have arrived but had to wait due to FIFO.
            # Wake up as many as are allowed.
            while self._value > 0:
                if not self._wake_up_next():
                    break  # There was no-one to wake up.
        return True

    def release(self) -> None:
        """Release a semaphore, incrementing the internal counter by one.

        When it was zero on entry and another task is waiting for it to
        become larger than zero again, wake up that task.
        """
        if self._value >= self._max_value:
            raise ValueError("Semaphore released too many times")

        self._value += 1
        self._wake_up_next()

    def __call__(self, priority: int = 0) -> WrappedContextManager[Self]:
        """Allows specifying the priority by calling the context manager.

        This allows both `async with sem:` and `async with sem(priority=5):`.
        """
        return WrappedContextManager(
            context_manager=self,
            on_enter=lambda: self.acquire(priority),
        )

    async def __aenter__(self) -> None:
        await self.acquire()

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        traceback: types.TracebackType | None,
    ) -> None:
        self.release()

    def __repr__(self) -> str:
        if self.locked():
            extra = f"locked, max value:{self._max_value}, waiters:{len(self._waiters)}"
        else:
            extra = f"unlocked, value:{self._value}, max value:{self._max_value}"

        return f"<{self.__class__.__name__} [{extra}]>"


class PriorityLock(PriorityDynamicBoundedSemaphore):
    def __init__(self) -> None:
        super().__init__(value=1)

    @PriorityDynamicBoundedSemaphore.max_value.setter
    def max_value(self, new_value: int) -> None:
        """Update the locks's max value."""
        raise ValueError("Max value of lock cannot be updated")


# Backwards compatibility
DynamicBoundedSemaphore = PriorityDynamicBoundedSemaphore


class ReschedulableTimeout:
    """Timeout object made to be efficiently rescheduled continuously."""

    def __init__(self, callback: typing.Callable[[], None]) -> None:
        self._timer: asyncio.TimerHandle | None = None
        self._callback = callback

        self._when: float = 0

    @functools.cached_property
    def _loop(self) -> asyncio.AbstractEventLoop:
        return asyncio.get_running_loop()

    def _timeout_trigger(self) -> None:
        now = self._loop.time()

        # If we triggered early, reschedule
        if self._when > now:
            self._reschedule()
            return

        self._timer = None
        self._callback()

    def _reschedule(self) -> None:
        if self._timer is not None:
            self._timer.cancel()

        self._timer = self._loop.call_at(self._when, self._timeout_trigger)

    def reschedule(self, delay: float) -> None:
        self._when = self._loop.time() + delay

        # If the current timer will expire too late (or isn't running), reschedule
        if self._timer is None or self._timer.when() > self._when:
            self._reschedule()

    def cancel(self) -> None:
        if self._timer is not None:
            self._timer.cancel()
            self._timer = None


class Debouncer:
    """Generic debouncer supporting per-invocation expiration."""

    def __init__(self) -> None:
        self._times: dict[typing.Any, float] = {}
        self._queue: list[tuple[float, int, typing.Any]] = []

        self._last_time: int = 0
        self._dedup_counter: int = 0

    @functools.cached_property
    def _loop(self) -> asyncio.AbstractEventLoop:
        return asyncio.get_running_loop()

    def clean(self, now: float | None = None) -> None:
        """Clean up stale timers."""
        if now is None:
            now = self._loop.time()

        # We store the negative expiration time to ensure we can pop expiring objects
        while self._queue and -self._queue[-1][0] < now:
            _, _, obj = self._queue.pop()
            self._times.pop(obj)

    def is_filtered(self, obj: typing.Any, now: float | None = None) -> bool:
        """Check if an object will be filtered."""
        if now is None:
            now = self._loop.time()

        # Clean up stale timers
        self.clean(now)

        # If an object still exists after cleaning, it won't be expired
        return obj in self._times

    def filter(self, obj: typing.Any, expire_in: float) -> bool:
        """Check if an object should be filtered. If not, store it."""
        now = self._loop.time()

        # For platforms with low-resolution clocks, we need to make sure that `obj` will
        # never be compared by `heapq`!
        if now > self._last_time:
            self._last_time = now
            self._dedup_counter = 0

        self._dedup_counter += 1

        # If the object is filtered, do nothing
        if self.is_filtered(obj, now=now):
            return True

        # Otherwise, queue it
        self._times[obj] = now + expire_in
        bisect.insort_right(self._queue, (-(now + expire_in), self._dedup_counter, obj))

        return False

    def __repr__(self) -> str:
        """String representation of the debouncer."""
        return f"<{self.__class__.__name__} [tracked:{len(self._queue)}]>"


class _LimiterContext:
    """Helper class to manage the async context for the RequestLimiter."""

    def __init__(self, limiter: RequestLimiter, priority: int) -> None:
        self._limiter = limiter
        self._priority = priority

    async def __aenter__(self) -> None:
        """Acquire a slot from the limiter."""
        await self._limiter._acquire(self._priority)

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        traceback: types.TracebackType | None,
    ) -> None:
        """Release the slot back to the limiter."""
        self._limiter._release(self._priority)


class RequestLimiter:
    """Limits concurrent requests with cascading capacity for multiple priority levels."""

    def __init__(self, max_concurrency: int, capacities: dict[int, float]) -> None:
        """Initializes the RequestLimiter."""
        if max_concurrency < 0:
            raise ValueError(f"max_concurrency must be >= 0: {max_concurrency}")

        self._lock = asyncio.Lock()
        self._capacity_fractions = capacities
        self._sorted_priorities = sorted(capacities.keys())

        self._cumulative_capacity: dict[int, int] = {}
        self._max_concurrency = max_concurrency
        self._recalculate_capacity()

        self._active_requests_by_tier: typing.Counter[int] = collections.Counter()
        self._waiters: list[tuple[int, int, asyncio.Future]] = []
        self._comparison_counter = 0

    @property
    def active_requests(self) -> int:
        """Returns the total number of currently running requests."""
        return sum(self._active_requests_by_tier.values())

    @property
    def waiting_requests(self) -> int:
        """Returns the number of requests waiting for a slot."""
        return len(self._waiters)

    @property
    def max_concurrency(self) -> int:
        """Returns the maximum concurrency of the limiter."""
        return self._max_concurrency

    @max_concurrency.setter
    def max_concurrency(self, new_value: int) -> None:
        """Updates the maximum concurrency of the limiter."""
        self._max_concurrency = new_value
        self._recalculate_capacity()
        self._wake_waiters()

    @property
    def max_value(self) -> int:
        """Deprecated alias for `max_concurrency`."""
        warnings.warn(
            "`max_value` is deprecated, use `max_concurrency` instead",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.max_concurrency

    @max_value.setter
    def max_value(self, new_value: int) -> None:
        """Deprecated setter alias for `max_concurrency`."""
        warnings.warn(
            "`max_value` is deprecated, use `max_concurrency` instead",
            DeprecationWarning,
            stacklevel=2,
        )
        self.max_concurrency = new_value

    def _recalculate_capacity(self) -> None:
        # Assume that all of the fractions are simple
        divisors = [
            Fraction.from_float(f).limit_denominator(10).denominator
            for f in self._capacity_fractions.values()
        ]

        lcm = math.lcm(*divisors)

        if self._max_concurrency % lcm != 0:
            next_best = lcm - self._max_concurrency % lcm
            assert next_best > 0

            _LOGGER.warning(
                "Requested adapter concurrency %d is not compatible with priority fractions %r. Increasing concurrency to %d.",
                self._max_concurrency,
                self._capacity_fractions,
                self._max_concurrency + next_best,
            )
            self._max_concurrency += next_best

        cumulative_capacity = 0

        for priority in self._sorted_priorities:
            portion = self._capacity_fractions[priority] * self._max_concurrency
            cumulative_capacity += round(portion)

            self._cumulative_capacity[priority] = cumulative_capacity

    def __call__(self, priority: int = 0) -> _LimiterContext:
        """Returns an async context manager to safely acquire and release a slot."""
        return _LimiterContext(self, priority)

    def _get_effective_priority_tier(self, priority: int) -> int:
        """Finds the capacity tier that the given priority falls into."""
        if priority < self._sorted_priorities[0]:
            raise ValueError(
                f"Priority {priority} is lower than the lowest known priority "
                f"{self._sorted_priorities[0]} and has no allocated capacity"
            )

        idx = bisect.bisect_right(self._sorted_priorities, priority)
        return self._sorted_priorities[idx - 1]

    def locked(self, priority: int) -> bool:
        """Checks if a request with a given priority can run."""
        effective_tier = self._get_effective_priority_tier(priority)
        limit = self._cumulative_capacity[effective_tier]
        waiting_requests = 0

        if limit == 0:
            return True

        for tier, count in self._active_requests_by_tier.items():
            if tier > effective_tier:
                continue

            waiting_requests += count
            if waiting_requests >= limit:
                return True

        return False

    def _wake_waiters(self) -> None:
        """Wakes up any waiting tasks that can now run."""
        while self._waiters:
            waiter_priority, _, fut = self._waiters[0]
            priority = -waiter_priority  # We flip the sign when storing, for comparison

            if not self.locked(priority):
                heapq.heappop(self._waiters)
                if not fut.done():
                    effective_tier = self._get_effective_priority_tier(priority)
                    self._active_requests_by_tier[effective_tier] += 1
                    fut.set_result(None)
            else:
                break

    async def _acquire(self, priority: int = 0) -> bool:
        """Acquires a slot in the limiter, waiting if necessary."""
        effective_tier = self._get_effective_priority_tier(priority)

        # A task can run immediately if it has capacity AND it has a higher
        # priority than any task already waiting. This allows high-priority
        # tasks to jump the queue, while maintaining FIFO for tasks of the
        # same priority.
        highest_waiter_priority = (
            -self._waiters[0][0] if self._waiters else -float("inf")
        )

        if not self.locked(priority) and priority > highest_waiter_priority:
            self._active_requests_by_tier[effective_tier] += 1
            return True

        # To ensure that our objects don't have to be themselves comparable, we
        # maintain a global count and increment it on every insert. This way,
        # the tuple `(-priority, count, item)` will never have to compare `item`.
        self._comparison_counter += 1
        fut = asyncio.get_running_loop().create_future()
        waiter_obj = (-priority, self._comparison_counter, fut)
        heapq.heappush(self._waiters, waiter_obj)

        try:
            try:
                await fut
                return True
            finally:
                if waiter_obj in self._waiters:
                    self._waiters.remove(waiter_obj)
                    heapq.heapify(self._waiters)
        except asyncio.CancelledError:
            # Only undo the bookkeeping if the slot was actually granted. If the future
            # instead carries an exception (e.g. from `cancel_waiting`), no slot was
            # ever acquired and there is nothing to release.
            if fut.done() and not fut.cancelled() and fut.exception() is None:
                self._active_requests_by_tier[effective_tier] -= 1

            raise
        finally:
            self._wake_waiters()

    def _release(self, priority: int = 0) -> None:
        """Releases an acquired slot back to the limiter."""
        effective_tier = self._get_effective_priority_tier(priority)
        assert self._active_requests_by_tier[effective_tier] > 0
        self._active_requests_by_tier[effective_tier] -= 1
        self._wake_waiters()

    def cancel_waiting(self, exc: BaseException) -> None:
        """Cancel all waiters with the given exception."""
        for _, _, fut in self._waiters:
            if not fut.done():
                fut.set_exception(exc)

    def __repr__(self) -> str:
        """Provides a string representation of the limiter's state."""
        return (
            f"<{self.__class__.__name__}("
            f"max_concurrency={self._max_concurrency}"
            f", active={self.active_requests}"
            f", waiting={self.waiting_requests}"
            f")>"
        )
