import base64
import json

import time
import requests
import threading

from typing import Optional, Callable, Iterable, List
from urllib.parse import urljoin

from frisk_sdk.logging.frisk_logger_name import FRISK_LOGGER_NAME
from frisk_sdk.errors.api_response_errors import (
    FriskBaseURLNotFoundError,
    InvalidAccessTokenError,
    FriskInvalidAPIKeyError,
    UnexpectedFriskServerResponseError,
)
from frisk_sdk.errors.configuration_errors import MissingBaseURLError
from frisk_sdk.errors.api_response_errors.get_frisk_api_error_from_http_status import (
    get_frisk_api_error_from_http_status,
)
from frisk_sdk.logging import get_logger

# Test-only hook used by unit tests that run refresh loops inline.
_LATEST_ACCESS_TOKEN_PROVIDER_FOR_TESTS: "AccessTokenProvider | None" = None


class AccessTokenProvider:
    """
    Manages access token (JWT) lifecycle:
    - Fetches token using an api_key
    - Parses JWT to extract exp
    - Refreshes token if expired or on a periodic cadence (configurable; interval is in minutes)
    """

    def __init__(
        self,
        api_key: str,
        *,
        refresh_interval_minutes: int,
        base_url: str,
        callbacks: Iterable[Callable[[str], None]],
        parent_logger_name: Optional[str] = None,
    ):
        self.logger = get_logger(
            (parent_logger_name or FRISK_LOGGER_NAME) + ".access_token_provider"
        )

        if not base_url:
            raise MissingBaseURLError()

        self.token_url = urljoin(base_url, "/api/agent_tokens/issue")

        self.api_key = api_key
        self.refresh_interval_seconds = max(
            60, refresh_interval_minutes * 60
        )  # minimum 60s to avoid thrash
        self._access_token: Optional[str] = None
        self._exp_epoch_s: Optional[int] = None
        self._last_refresh_epoch_s: Optional[int] = None
        # Background refresh machinery
        self._bg_thread: Optional[threading.Thread] = None
        self._bg_stop_event: Optional[threading.Event] = None
        self._lock = threading.Lock()
        self._safety_margin_s = 30  # proactively refresh if exp is within 30s

        self._callbacks: List[Callable[[str], None]] = list(callbacks)

    def fetch_access_token(self) -> str:
        """Calls Frisk API to exchange api_key for short-lived access token (JWT)."""
        try:
            resp = requests.post(
                self.token_url,
                headers={"Authorization": f"Bearer {self.api_key}"},
                timeout=10,
            )

        except requests.exceptions.RequestException as e:
            raise FriskBaseURLNotFoundError() from e

        if resp.status_code != 200:
            # try to extract message for context
            error_details = None
            try:
                error_details = resp.json()
            except Exception:
                error_details = resp.text
            raise get_frisk_api_error_from_http_status(resp.status_code, error_details)

        try:
            data = resp.json()
        except json.JSONDecodeError as e:
            raise UnexpectedFriskServerResponseError() from e

        token = data.get("access_token")
        if not isinstance(token, str) or not token:
            raise UnexpectedFriskServerResponseError() from ValueError(
                "Issuer response missing 'access_token' string"
            )
        return token

    def add_callback(self, callback: Callable[[str], None]) -> None:
        self._callbacks.append(callback)

    def _decode_jwt_payload(self, token: str) -> dict:
        """Decode a JWT without verifying signature to read payload fields like exp."""
        try:
            parts = token.split(".")
            if len(parts) != 3:
                raise InvalidAccessTokenError()
            payload_b64 = parts[1]
            # Fix base64url padding
            padding = "=" * (-len(payload_b64) % 4)
            payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
            return json.loads(payload_bytes.decode("utf-8"))
        except Exception:
            raise InvalidAccessTokenError()

    def _update_cached_token(self, token: str) -> None:
        payload = self._decode_jwt_payload(token)
        exp = payload.get("exp")
        if not isinstance(exp, int):
            raise InvalidAccessTokenError()
        now = int(time.time())
        old_token = self._access_token
        self._access_token = token
        if old_token != token:
            for callback in self._callbacks:
                callback(token)

        self._exp_epoch_s = exp
        self._last_refresh_epoch_s = now

    def _needs_refresh(self, now_s: int) -> bool:
        # If no token yet, we need one
        if not self._access_token or not self._exp_epoch_s:
            return True
        # If token expired or exp is near, refresh early
        if (self._exp_epoch_s - now_s) <= self._safety_margin_s:
            return True
        # Periodic refresh window reached
        if (now_s - (self._last_refresh_epoch_s or 0)) >= self.refresh_interval_seconds:
            return True
        return False

    def get_access_token(self) -> str:
        """
        Returns a valid access token. Refreshes if expired or refresh interval reached.
        Thread-safe: uses a lock to prevent concurrent refreshes.
        """
        now = int(time.time())
        if self._needs_refresh(now):
            with self._lock:
                # re-check after acquiring lock
                now2 = int(time.time())
                if self._needs_refresh(now2):
                    token = self.fetch_access_token()
                    self._update_cached_token(token)

        if not self._access_token:
            # Should not happen; defensive
            raise FriskInvalidAPIKeyError()

        return self._access_token

    def get_token_expiry(self) -> Optional[int]:
        """Returns exp epoch seconds if known."""
        return self._exp_epoch_s

    def force_refresh(self) -> str:
        """Forces a token refresh regardless of exp/interval."""
        with self._lock:
            token = self.fetch_access_token()
            self._update_cached_token(token)
        return token

    def start_background_refresh(self) -> None:
        """Starts a daemon thread that periodically refreshes the token."""
        if self._bg_thread and self._bg_thread.is_alive():
            return  # already running

        self._bg_stop_event = threading.Event()

        def _runner() -> None:
            # A simple loop: wake periodically, refresh if needed
            # (We still rely on get_access_token() logic to decide.)
            while self._bg_stop_event and not self._bg_stop_event.is_set():
                try:
                    self.get_access_token()
                except Exception as e:
                    # Avoid crashing the thread; log and retry later
                    try:
                        self.logger.warning(f"Token refresh failed: {e}")
                    except Exception:
                        pass
                # Sleep a bit; we don’t want tight looping
                time.sleep(5)

        self._bg_thread = threading.Thread(
            target=_runner, name="AccessTokenProviderRefresher", daemon=True
        )
        self._bg_thread.start()

    def stop_background_refresh(self) -> None:
        """Signals the background thread to stop and waits briefly."""
        if self._bg_stop_event:
            self._bg_stop_event.set()
        if self._bg_thread and self._bg_thread.is_alive():
            # Wait up to a short grace period
            self._bg_thread.join(timeout=2)
        self._bg_thread = None
        self._bg_stop_event = None
