# @File: backend/services/api_hub_credentials.py
# @Desc: Service layer for reading and writing API Hub provider credentials.
#
# Responsibilities:
#   * Resolve a provider's effective credential value using the precedence
#     chain: DB row (decrypted) > environment variable > None.
#   * Persist new / updated credentials encrypted at rest.
#   * Delete (clear) a stored credential so the provider falls back to the
#     environment variable, if any.
#
# All callers should use `resolve_credential()` instead of `os.getenv()`
# for anything declared in the API Hub registry, so admin-UI changes take
# effect without a restart.
import logging
import os
from typing import Dict, Iterable, Optional

from core.database import db_manager
from core.mask_crypto import decrypt_text, encrypt_text
from models.api_hub_credentials import ApiHubCredential
from sqlalchemy import delete, select


def _session_maker():
    """Return the app's async session maker, initializing lazily if needed."""
    maker = db_manager.async_session_maker
    if maker is None:
        raise RuntimeError("Database is not initialized yet.")
    return maker

logger = logging.getLogger(__name__)


# In-process cache for decrypted values keyed by (provider_id, name). The
# cache is invalidated after every write so admin updates take effect
# immediately; reads during a single request burst stay cheap.
_CACHE: Dict[tuple[str, str], Optional[str]] = {}


def _cache_key(provider_id: str, name: str) -> tuple[str, str]:
    return (provider_id, name)


def invalidate_cache(provider_id: Optional[str] = None) -> None:
    """Drop cached decrypted credentials for a provider or for all providers."""
    if provider_id is None:
        _CACHE.clear()
        return
    for key in [k for k in _CACHE if k[0] == provider_id]:
        _CACHE.pop(key, None)


async def load_provider_credentials(provider_id: str) -> Dict[str, str]:
    """Return all DB-stored credentials for a provider (decrypted, plain text).

    Falls back to an empty dict if the provider has no overrides. Errors
    during decryption are logged and the affected credential is skipped so
    one corrupted row cannot break the whole provider.
    """
    results: Dict[str, str] = {}
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = select(ApiHubCredential).where(ApiHubCredential.provider_id == provider_id)
        rows = (await session.execute(stmt)).scalars().all()
    for row in rows:
        try:
            plain = decrypt_text(row.encrypted_value)
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "Failed to decrypt credential %s for provider %s: %s",
                row.name,
                provider_id,
                exc,
            )
            continue
        results[row.name] = plain
        _CACHE[_cache_key(provider_id, row.name)] = plain
    return results


async def resolve_credential(provider_id: str, name: str) -> Optional[str]:
    """Resolve a single credential using DB > env precedence."""
    cached = _CACHE.get(_cache_key(provider_id, name))
    if cached is not None:
        return cached
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = select(ApiHubCredential).where(
            ApiHubCredential.provider_id == provider_id,
            ApiHubCredential.name == name,
        )
        row = (await session.execute(stmt)).scalar_one_or_none()
    if row is not None:
        try:
            plain = decrypt_text(row.encrypted_value)
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "Failed to decrypt credential %s for provider %s: %s",
                name,
                provider_id,
                exc,
            )
            plain = None
        if plain is not None:
            _CACHE[_cache_key(provider_id, name)] = plain
            return plain
    env_val = os.getenv(name)
    return env_val or None


async def resolve_many(provider_id: str, names: Iterable[str]) -> Dict[str, Optional[str]]:
    """Resolve multiple credentials for the same provider in one shot."""
    db_values = await load_provider_credentials(provider_id)
    out: Dict[str, Optional[str]] = {}
    for name in names:
        if name in db_values:
            out[name] = db_values[name]
        else:
            val = os.getenv(name)
            out[name] = val or None
    return out


async def has_stored_value(provider_id: str, name: str) -> bool:
    """Return True iff a DB row exists for (provider_id, name)."""
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = select(ApiHubCredential.id).where(
            ApiHubCredential.provider_id == provider_id,
            ApiHubCredential.name == name,
        )
        return (await session.execute(stmt)).scalar_one_or_none() is not None


async def stored_names_for(provider_id: str) -> set[str]:
    """Return the set of credential names stored in the DB for a provider."""
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = select(ApiHubCredential.name).where(
            ApiHubCredential.provider_id == provider_id
        )
        rows = (await session.execute(stmt)).scalars().all()
    return set(rows)


async def set_credential(
    provider_id: str,
    name: str,
    value: str,
    updated_by: Optional[str] = None,
) -> None:
    """Insert or update an encrypted credential.

    Empty strings are treated as a delete so the UI can "clear" a value
    without requiring a separate endpoint. Trimming leading/trailing
    whitespace protects against accidental copy-paste newlines.
    """
    if value is None or value.strip() == "":
        await delete_credential(provider_id, name)
        return
    encrypted = encrypt_text(value.strip())
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = select(ApiHubCredential).where(
            ApiHubCredential.provider_id == provider_id,
            ApiHubCredential.name == name,
        )
        row = (await session.execute(stmt)).scalar_one_or_none()
        if row is None:
            row = ApiHubCredential(
                provider_id=provider_id,
                name=name,
                encrypted_value=encrypted,
                updated_by=updated_by,
            )
            session.add(row)
        else:
            row.encrypted_value = encrypted
            row.updated_by = updated_by
        await session.commit()
    _CACHE[_cache_key(provider_id, name)] = value.strip()


async def delete_credential(provider_id: str, name: str) -> bool:
    """Remove a stored credential. Returns True if a row was deleted."""
    await db_manager.ensure_initialized()
    async with _session_maker()() as session:
        stmt = delete(ApiHubCredential).where(
            ApiHubCredential.provider_id == provider_id,
            ApiHubCredential.name == name,
        )
        result = await session.execute(stmt)
        await session.commit()
    _CACHE.pop(_cache_key(provider_id, name), None)
    return bool(result.rowcount or 0)