"""Token lifecycle manager — the core of frameio.auth.

Stores access/refresh tokens, checks expiry with a configurable buffer,
and coordinates thread-safe automatic refresh.
"""

from __future__ import annotations

import logging
import threading
import time
from typing import Any, Callable, Optional

from ._errors import (
    AuthenticationError,
    NetworkError,
    RateLimitError,
    TokenExpiredError,
)
from ._http import DEFAULT_REFRESH_BUFFER_SECONDS
from ._validation import _validate_token_import

logger = logging.getLogger("frameio.auth")


class TokenManager:
    """Manages token storage, expiry detection, and auto-refresh.

    Args:
        refresh_func: A callable ``() -> dict`` that performs the actual token
            refresh (or initial fetch) and returns a dict with at least
            ``access_token`` and ``expires_in``.  May also contain
            ``refresh_token``.
        refresh_buffer: Seconds before actual expiry to trigger a proactive
            refresh.  Defaults to 60.
        on_token_refreshed: Optional callback ``(tokens: dict) -> None`` fired
            after every successful refresh, useful for persisting tokens.
    """

    def __init__(
        self,
        refresh_func: Callable[[], dict[str, Any]],
        *,
        refresh_buffer: int = DEFAULT_REFRESH_BUFFER_SECONDS,
        on_token_refreshed: Optional[Callable[[dict[str, Any]], None]] = None,
    ) -> None:
        self._refresh_func = refresh_func
        self._refresh_buffer = refresh_buffer
        self._on_token_refreshed = on_token_refreshed

        self._access_token: str | None = None
        self._refresh_token: str | None = None
        self._expires_at: float = 0.0

        # Thread-safety: Condition variable for coordinating refresh.
        # The lock protects token state; the condition lets waiting threads
        # be notified when a refresh completes, without holding the lock
        # during the network call itself.
        self._condition = threading.Condition()
        self._refreshing = False
        self._refresh_error: BaseException | None = None

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def get_token(self) -> str:
        """Return a valid access token, refreshing if necessary.

        This is the callable you pass to the Frame.io SDK::

            client = Frameio(token=manager.get_token)

        Returns:
            A valid access token string.

        Raises:
            AuthenticationError: If refresh fails.
            TokenExpiredError: If the refresh token itself is expired.
        """
        if self._is_token_valid():
            return self._access_token  # type: ignore[return-value]

        return self._do_refresh()

    @property
    def access_token(self) -> str | None:
        """The current access token (may be expired)."""
        return self._access_token

    @property
    def refresh_token(self) -> str | None:
        """The current refresh token."""
        return self._refresh_token

    @property
    def expires_at(self) -> float:
        """Unix timestamp when the access token expires."""
        return self._expires_at

    @property
    def is_expired(self) -> bool:
        """Whether the access token is expired (or will be within the buffer)."""
        return not self._is_token_valid()

    def set_tokens(
        self,
        access_token: str,
        expires_in: int,
        refresh_token: str | None = None,
    ) -> None:
        """Manually set token data (e.g. after initial code exchange).

        Args:
            access_token: The new access token.
            expires_in: Seconds until the token expires.
            refresh_token: Optional refresh token.
        """
        with self._condition:
            self._access_token = access_token
            self._expires_at = time.time() + expires_in
            if refresh_token is not None:
                self._refresh_token = refresh_token

    def export_tokens(self) -> dict[str, Any]:
        """Export current token state for persistence.

        Returns:
            Dict with ``access_token``, ``refresh_token``, ``expires_at``.
        """
        return {
            "access_token": self._access_token,
            "refresh_token": self._refresh_token,
            "expires_at": self._expires_at,
        }

    def import_tokens(self, data: dict[str, Any]) -> None:
        """Restore token state from a previously exported dict.

        Args:
            data: A dict produced by :meth:`export_tokens`.

        Raises:
            ConfigurationError: If *data* has invalid types or structure.
        """
        _validate_token_import(data)
        with self._condition:
            self._access_token = data.get("access_token")
            self._refresh_token = data.get("refresh_token")
            self._expires_at = data.get("expires_at", 0.0)

    def clear(self) -> None:
        """Clear all stored tokens."""
        with self._condition:
            self._access_token = None
            self._refresh_token = None
            self._expires_at = 0.0

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _is_token_valid(self) -> bool:
        return (
            self._access_token is not None
            and time.time() < (self._expires_at - self._refresh_buffer)
        )

    def _do_refresh(self) -> str:
        """Perform a thread-safe refresh.

        The network call happens OUTSIDE the lock so other threads are not
        blocked for the duration of the HTTP request (which may include
        retries).  Only one thread performs the refresh; others wait on the
        condition variable and reuse the result.
        """
        while True:
            is_owner = False

            with self._condition:
                # Fast path: another thread already refreshed while we waited
                if self._is_token_valid():
                    return self._access_token  # type: ignore[return-value]

                # If a previous refresh failed, propagate its error to waiters
                if self._refresh_error is not None:
                    err = self._refresh_error
                    self._refresh_error = None
                    raise err

                if self._refreshing:
                    # Another thread is refreshing — wait for it to finish
                    while self._refreshing:
                        self._condition.wait()
                    # Re-check token validity and error at top of loop
                    continue

                self._refreshing = True
                is_owner = True

            # ------- Network call happens here, OUTSIDE the lock -------
            assert is_owner
            break

        logger.debug("Token refresh triggered.")
        try:
            result = self._refresh_func()
        except (
            AuthenticationError,
            TokenExpiredError,
            NetworkError,
            RateLimitError,
        ) as exc:
            with self._condition:
                self._refresh_error = exc
                self._refreshing = False
                self._condition.notify_all()
            raise
        except Exception as exc:
            auth_err = AuthenticationError(
                "refresh_failed",
                f"Token refresh failed: {exc}",
            )
            with self._condition:
                self._refresh_error = auth_err
                self._refreshing = False
                self._condition.notify_all()
            raise auth_err from exc

        # ------- Update state under the lock -------
        with self._condition:
            self._access_token = result["access_token"]
            self._expires_at = time.time() + result.get("expires_in", 86400)
            if "refresh_token" in result:
                self._refresh_token = result["refresh_token"]

            self._refresh_error = None
            self._refreshing = False
            self._condition.notify_all()

        logger.info(
            "Token refreshed (length=%d).",
            len(self._access_token) if self._access_token else 0,
        )

        if self._on_token_refreshed:
            try:
                self._on_token_refreshed(self.export_tokens())
            except Exception:
                logger.exception("on_token_refreshed callback raised; continuing")

        assert self._access_token is not None
        return self._access_token
