"""Centralized API credential management for dotpromptz.

Loads credentials from the API_CREDENTIALS env var (inline JSON, @filepath,
or @url). Falls back to a default remote URL when unset. Supports
round-robin selection, group filtering, and model-specific matching.
"""

import json
import logging
import os
import ssl
import subprocess  # noqa: S404
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from dotpromptz.yamlio import YamlError, load_yaml

_DEFAULT_CREDENTIALS_URL = (
    'https://raw.githubusercontent.com/my-three-kingdoms/dotpromptz/refs/heads/main/credentials.yaml'
)
_URL_FETCH_TIMEOUT_SECONDS = 30
_URL_FETCH_TIMEOUT_RETRIES = 2
_URL_FETCH_RETRY_DELAY_SECONDS = 0.5

logger = logging.getLogger(__name__)


def _is_timeout_error(reason: Any) -> bool:
    """Return whether *reason* represents a timeout."""
    return isinstance(reason, TimeoutError) or 'timed out' in str(reason).lower()


def _is_retryable_ssl_eof_error(reason: Any) -> bool:
    """Return whether *reason* is a transient TLS EOF error."""
    text = str(reason).lower()
    if isinstance(reason, ssl.SSLEOFError):
        return True
    return 'unexpected eof while reading' in text or 'eof occurred in violation of protocol' in text


def _is_retryable_network_error(reason: Any) -> bool:
    """Return whether *reason* should trigger URL fetch retry."""
    return _is_timeout_error(reason) or _is_retryable_ssl_eof_error(reason)


def _get_auth_token() -> str:
    """Return a Bearer token for remote credential fetching.

    Resolution order:
    1. ``API_CREDENTIALS_TOKEN`` environment variable.
    2. ``gh auth token`` CLI command (GitHub CLI auto-login).
    3. Empty string (no auth).
    """
    token = os.environ.get('API_CREDENTIALS_TOKEN', '').strip()
    if token:
        return token

    # Fallback: try GitHub CLI
    try:
        result = subprocess.run(  # noqa: S603, S607
            ['gh', 'auth', 'token'],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            gh_token = result.stdout.strip()
            if gh_token:
                logger.debug('Using gh auth token as credential token fallback')
                return gh_token
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    return ''


@dataclass(frozen=True)
class Credential:
    """A single named credential entry.

    Attributes:
        name: Human-readable group name (e.g. ``'azure-east'``).
        adapter: Adapter type this credential is for (``'openai'``, etc.).
        api_key: The API key.
        base_url: Optional custom API base URL.
        models: Optional list of model names this credential supports.
            When ``None``, the credential matches all models.
    """

    name: str
    adapter: str
    api_key: str
    base_url: str | None = None
    models: tuple[str, ...] | None = None


class CredentialPool:
    """Thread-safe round-robin credential pool.

    Credentials are grouped by ``(adapter, name)``.  Calling :meth:`next`
    returns the next credential for the requested adapter, cycling through
    all matching entries.
    """

    def __init__(self, credentials: list[Credential]) -> None:
        """Initialise the pool with a list of credentials."""
        self._credentials = list(credentials)
        # Per-adapter round-robin index
        self._indices: dict[str, int] = {}
        self._lock = threading.Lock()

    @property
    def credentials(self) -> list[Credential]:
        """Return a copy of all credentials."""
        return list(self._credentials)

    # ------------------------------------------------------------------
    # Filtering
    # ------------------------------------------------------------------

    def filter(
        self,
        adapter: str,
        *,
        group: str | None = None,
        groups: list[str] | None = None,
        model: str | None = None,
    ) -> list[Credential]:
        """Return credentials matching *adapter* and optional filters.

        Args:
            adapter: Adapter name to match (e.g. ``'openai'``).
            group: If given, only return the credential with this name.
            groups: If given, only return credentials whose names are in this
                list.
            model: If given, only return credentials that support this model
                (i.e. ``models`` is ``None`` or contains *model*).

        Returns:
            A (possibly empty) list of matching ``Credential`` objects.
        """
        result = [c for c in self._credentials if c.adapter == adapter]
        if group is not None:
            result = [c for c in result if c.name == group]
        elif groups is not None:
            names_set = set(groups)
            result = [c for c in result if c.name in names_set]
        if model is not None:
            result = [c for c in result if c.models is None or model in c.models]
        return result

    # ------------------------------------------------------------------
    # Round-robin selection
    # ------------------------------------------------------------------

    def next(
        self,
        adapter: str,
        *,
        group: str | None = None,
        groups: list[str] | None = None,
        model: str | None = None,
    ) -> Credential:
        """Return the next credential via round-robin.

        Args:
            adapter: Adapter name to match.
            group: Optional single group name filter.
            groups: Optional list of group names to rotate among.
            model: Optional model name filter.  Only credentials whose
                ``models`` is ``None`` or contains *model* are considered.

        Returns:
            The next ``Credential`` in rotation.

        Raises:
            ValueError: If no credentials match the filter.
        """
        candidates = self.filter(adapter, group=group, groups=groups, model=model)
        if not candidates:
            filter_desc = f'adapter={adapter!r}'
            if group:
                filter_desc += f', group={group!r}'
            elif groups:
                filter_desc += f', groups={groups!r}'
            if model:
                filter_desc += f', model={model!r}'
            msg = f'No credentials found for {filter_desc}. Check your API_CREDENTIALS environment variable.'
            raise ValueError(msg)

        # Build a stable key for the index counter
        key = adapter
        if group:
            key += f':{group}'
        elif groups:
            key += ':' + ','.join(sorted(groups))
        if model:
            key += f'@{model}'

        with self._lock:
            idx = self._indices.get(key, 0)
            cred = candidates[idx % len(candidates)]
            self._indices[key] = idx + 1
            return cred

    def __len__(self) -> int:
        """Return the number of credentials in the pool."""
        return len(self._credentials)

    def __bool__(self) -> bool:
        """Return whether the pool has any credentials."""
        return len(self._credentials) > 0


# ----------------------------------------------------------------------
# Loading
# ----------------------------------------------------------------------


def _parse_credential_list(raw: list[dict[str, Any]]) -> list[Credential]:
    """Validate and convert raw dicts to ``Credential`` objects."""
    credentials: list[Credential] = []
    for i, entry in enumerate(raw):
        missing = [k for k in ('name', 'adapter', 'api_key') if k not in entry]
        if missing:
            msg = f'Credential entry [{i}] is missing required fields: {", ".join(missing)}'
            raise ValueError(msg)
        credentials.append(
            Credential(
                name=entry['name'],
                adapter=entry['adapter'],
                api_key=entry['api_key'],
                base_url=entry.get('base_url'),
                models=tuple(entry['models']) if entry.get('models') else None,
            )
        )
    return credentials


def load_credentials(
    env_var: str = 'API_CREDENTIALS',
) -> CredentialPool:
    """Load credentials from the ``API_CREDENTIALS`` environment variable.

    The value may be:

    * A JSON array string.
    * A path prefixed with ``@`` pointing to a JSON or YAML file.
    * A URL prefixed with ``@`` (``@https://...``) to fetch remotely.
      Set ``API_CREDENTIALS_TOKEN`` for Bearer auth (e.g. GitHub PAT).

    Args:
        env_var: Name of the environment variable to read (default
            ``'API_CREDENTIALS'``).

    Returns:
        A ``CredentialPool`` instance.  When the env var is unset, credentials
        are loaded from the hardcoded default URL.

    Raises:
        ValueError: If the value cannot be parsed or validated.
    """
    value = os.environ.get(env_var, '').strip()
    if not value:
        return _load_from_url(_DEFAULT_CREDENTIALS_URL)

    if value.startswith('@'):
        target = value[1:]
        if target.startswith(('https://', 'http://')):
            return _load_from_url(target)
        return _load_from_file(target)

    return _load_from_json(value)


def _load_from_json(text: str) -> CredentialPool:
    """Parse inline JSON credential array."""
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        msg = f'API_CREDENTIALS is not valid JSON: {exc}'
        raise ValueError(msg) from exc

    if not isinstance(data, list):
        msg = 'API_CREDENTIALS must be a JSON array of credential objects.'
        raise ValueError(msg)

    return CredentialPool(_parse_credential_list(data))


def _load_from_file(filepath: str) -> CredentialPool:
    """Load credentials from a JSON or YAML file."""
    path = Path(filepath).expanduser()
    if not path.is_file():
        msg = f'API_CREDENTIALS file not found: {path}'
        raise ValueError(msg)

    content = path.read_text(encoding='utf-8')
    suffix = path.suffix.lower()

    if suffix in ('.yaml', '.yml'):
        try:
            data = load_yaml(content)
        except YamlError as exc:
            msg = f'Failed to parse credential file {path}: {exc}'
            raise ValueError(msg) from exc
    elif suffix == '.json':
        try:
            data = json.loads(content)
        except json.JSONDecodeError as exc:
            msg = f'Failed to parse credential file {path}: {exc}'
            raise ValueError(msg) from exc
    else:
        # Try JSON first, then YAML
        try:
            data = json.loads(content)
        except json.JSONDecodeError:
            try:
                data = load_yaml(content)
            except YamlError as exc:
                msg = f'Cannot parse credential file {path}: {exc}'
                raise ValueError(msg) from exc

    if not isinstance(data, list):
        msg = f'Credential file {path} must contain an array of credential objects.'
        raise ValueError(msg)

    return CredentialPool(_parse_credential_list(data))


def _load_from_url(url: str) -> CredentialPool:
    """Fetch credentials from a remote URL.

    Uses ``_get_auth_token()`` for Bearer auth — checks
    ``API_CREDENTIALS_TOKEN`` env var first, then falls back to
    ``gh auth token`` CLI command.
    The response is parsed as JSON first, then YAML as a fallback.
    """
    req = urllib.request.Request(url)  # noqa: S310
    token = _get_auth_token()
    if token:
        req.add_header('Authorization', f'Bearer {token}')
    # GitHub raw content needs this for private repos with PAT
    req.add_header('Accept', 'application/vnd.github.raw+json')

    for attempt in range(_URL_FETCH_TIMEOUT_RETRIES + 1):
        try:
            with urllib.request.urlopen(req, timeout=_URL_FETCH_TIMEOUT_SECONDS) as resp:  # noqa: S310
                content = resp.read().decode('utf-8')
            break
        except urllib.error.HTTPError as exc:
            msg = f'Failed to fetch credentials from {url}: HTTP {exc.code}'
            if (
                exc.code == 404
                and url.startswith('https://raw.githubusercontent.com/')
                and not token
            ):
                msg += ' (可能是私有仓库未授权：请设置 API_CREDENTIALS_TOKEN 或先执行 gh auth login)'
            raise ValueError(msg) from exc
        except urllib.error.URLError as exc:
            reason = exc.reason
            is_retryable = _is_retryable_network_error(reason)
            if is_retryable and attempt < _URL_FETCH_TIMEOUT_RETRIES:
                delay = _URL_FETCH_RETRY_DELAY_SECONDS * (attempt + 1)
                logger.warning(
                    'Credential fetch failed with retryable network error, retrying: url=%s attempt=%s/%s delay=%.2fs reason=%s',
                    url,
                    attempt + 1,
                    _URL_FETCH_TIMEOUT_RETRIES + 1,
                    delay,
                    reason,
                )
                time.sleep(delay)
                continue

            msg = f'Failed to fetch credentials from {url}: {reason}'
            if is_retryable:
                msg += f' (after {attempt + 1} attempts)'
            raise ValueError(msg) from exc
        except ssl.SSLError as exc:
            # Defensive: depending on Python/OpenSSL, TLS errors may surface
            # directly rather than wrapped as URLError.
            is_retryable = _is_retryable_network_error(exc)
            if is_retryable and attempt < _URL_FETCH_TIMEOUT_RETRIES:
                delay = _URL_FETCH_RETRY_DELAY_SECONDS * (attempt + 1)
                logger.warning(
                    'Credential fetch failed with retryable TLS error, retrying: url=%s attempt=%s/%s delay=%.2fs reason=%s',
                    url,
                    attempt + 1,
                    _URL_FETCH_TIMEOUT_RETRIES + 1,
                    delay,
                    exc,
                )
                time.sleep(delay)
                continue
            msg = f'Failed to fetch credentials from {url}: {exc}'
            if is_retryable:
                msg += f' (after {attempt + 1} attempts)'
            raise ValueError(msg) from exc

    # Parse response: try JSON first, then YAML
    try:
        data = json.loads(content)
    except json.JSONDecodeError:
        try:
            data = load_yaml(content)
        except YamlError as exc:
            msg = f'Cannot parse remote credentials from {url}: {exc}'
            raise ValueError(msg) from exc

    if not isinstance(data, list):
        msg = f'Remote credentials from {url} must be an array of credential objects.'
        raise ValueError(msg)

    return CredentialPool(_parse_credential_list(data))
