from __future__ import annotations

from datetime import datetime, timedelta, timezone
import json
import logging
import re
from typing import Any, Dict, Optional
from urllib.parse import urlencode

import httpx
from sqlalchemy.ext.asyncio import AsyncSession

from .. import settings
from .secret_storage import get_saas_secret_backend

logger = logging.getLogger(__name__)

ZOOMINFO_INTEGRATION_KEY = "zoominfo"
ZOOMINFO_ACCESS_TOKEN_KEY = "access_token"
ZOOMINFO_REFRESH_TOKEN_KEY = "refresh_token"
ZOOMINFO_METADATA_KEY = "oauth_metadata"
TOKEN_REFRESH_SKEW_SECONDS = 300


class ZoomInfoOAuthError(RuntimeError):
    def __init__(
        self,
        message: str,
        *,
        status_code: int = 500,
        code: str = "zoominfo_oauth_error",
        retryable: bool = False,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.code = code
        self.retryable = retryable


_SENSITIVE_TOKEN_PATTERNS = (
    re.compile(r"(access_token=)([^&\s]+)", re.IGNORECASE),
    re.compile(r"(refresh_token=)([^&\s]+)", re.IGNORECASE),
    re.compile(r"(authorization:\s*bearer\s+)([^\s]+)", re.IGNORECASE),
    re.compile(r"(bearer\s+)([a-z0-9\-_\.]{10,})", re.IGNORECASE),
)


def sanitize_zoominfo_error_text(message: str) -> str:
    """Redact likely token values from provider/network error text."""
    sanitized = message or ""
    for pattern in _SENSITIVE_TOKEN_PATTERNS:
        sanitized = pattern.sub(r"\1[REDACTED]", sanitized)
    return sanitized


def _now_utc() -> datetime:
    return datetime.now(timezone.utc)


def _clean_iso(value: str | None) -> Optional[datetime]:
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _authorize_url() -> str:
    value = (settings.ZOOMINFO_OAUTH_AUTHORIZE_URL or "").strip()
    if not value:
        raise ZoomInfoOAuthError(
            "ZoomInfo OAuth authorize URL is not configured on the server.",
            status_code=503,
            code="zoominfo_oauth_not_configured",
            retryable=False,
        )
    return value


def _token_url() -> str:
    value = (settings.ZOOMINFO_OAUTH_TOKEN_URL or "").strip()
    if not value:
        raise ZoomInfoOAuthError(
            "ZoomInfo OAuth token URL is not configured on the server.",
            status_code=503,
            code="zoominfo_oauth_not_configured",
            retryable=False,
        )
    return value


def _userinfo_url() -> str:
    return (settings.ZOOMINFO_OAUTH_USERINFO_URL or "").strip()


def _revoke_url() -> str:
    return (settings.ZOOMINFO_OAUTH_REVOKE_URL or "").strip()


def parse_zoominfo_scopes() -> list[str]:
    raw = settings.ZOOMINFO_OAUTH_SCOPES or ""
    compact = raw.replace(",", " ")
    scopes = [scope.strip() for scope in compact.split() if scope.strip()]
    return scopes


def build_zoominfo_auth_url(
    *,
    state: str,
    redirect_uri: str,
    code_challenge: str | None = None,
) -> str:
    scopes = parse_zoominfo_scopes()
    params = {
        "response_type": "code",
        "client_id": settings.ZOOMINFO_CLIENT_ID,
        "redirect_uri": redirect_uri,
        "state": state,
    }
    if scopes:
        params["scope"] = " ".join(scopes)
    if code_challenge:
        params["code_challenge"] = code_challenge
        params["code_challenge_method"] = "S256"
    if settings.ZOOMINFO_OAUTH_PROMPT:
        params["prompt"] = settings.ZOOMINFO_OAUTH_PROMPT
    return f"{_authorize_url()}?{urlencode(params)}"


def _classify_http_exception(exc: Exception) -> ZoomInfoOAuthError:
    message = sanitize_zoominfo_error_text(str(exc))
    lower = message.lower()
    if isinstance(exc, httpx.ConnectError) or "failed to resolve" in lower or "getaddrinfo" in lower:
        return ZoomInfoOAuthError(
            "Network/DNS to ZoomInfo is unavailable from this environment.",
            status_code=503,
            code="zoominfo_network_unavailable",
            retryable=True,
        )
    if isinstance(exc, httpx.ReadTimeout):
        return ZoomInfoOAuthError(
            "ZoomInfo request timed out.",
            status_code=504,
            code="zoominfo_timeout",
            retryable=True,
        )
    return ZoomInfoOAuthError(
        "ZoomInfo API request failed.",
        status_code=502,
        code="zoominfo_api_unreachable",
        retryable=True,
    )


def _sanitize_zoominfo_error(status_code: int, payload: Dict[str, Any]) -> ZoomInfoOAuthError:
    error_value = str(payload.get("error") or "").strip().lower()
    error_description = sanitize_zoominfo_error_text(
        str(payload.get("error_description") or payload.get("message") or "").strip()
    )
    if error_value in {"invalid_client", "invalid_grant", "unauthorized_client"}:
        return ZoomInfoOAuthError(
            error_description or "ZoomInfo OAuth configuration is invalid.",
            status_code=401,
            code=error_value or "zoominfo_auth_error",
            retryable=False,
        )
    if status_code in (401, 403):
        return ZoomInfoOAuthError(
            error_description or "ZoomInfo authorization failed.",
            status_code=401,
            code="zoominfo_unauthorized",
            retryable=False,
        )
    if status_code >= 500:
        return ZoomInfoOAuthError(
            "ZoomInfo service error.",
            status_code=502,
            code="zoominfo_upstream_error",
            retryable=True,
        )
    return ZoomInfoOAuthError(
        error_description or "ZoomInfo OAuth request failed.",
        status_code=400,
        code="zoominfo_oauth_failed",
        retryable=False,
    )


async def _post_token(data: Dict[str, str]) -> Dict[str, Any]:
    token_url = _token_url()
    timeout = httpx.Timeout(30.0)
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.post(token_url, data=data)
    except Exception as exc:  # pragma: no cover - network edge cases
        raise _classify_http_exception(exc) from exc

    payload: Dict[str, Any]
    try:
        payload = response.json()
    except ValueError:
        payload = {}

    if not response.is_success:
        raise _sanitize_zoominfo_error(response.status_code, payload)

    if not payload.get("access_token"):
        raise ZoomInfoOAuthError(
            "ZoomInfo response missing access_token.",
            status_code=502,
            code="zoominfo_missing_access_token",
            retryable=False,
        )
    return payload


async def exchange_code_for_tokens(
    *,
    code: str,
    redirect_uri: str,
    code_verifier: str | None = None,
) -> Dict[str, Any]:
    if not settings.ZOOMINFO_CLIENT_ID or not settings.ZOOMINFO_CLIENT_SECRET:
        raise ZoomInfoOAuthError(
            "ZoomInfo OAuth is not configured on the server.",
            status_code=503,
            code="zoominfo_oauth_not_configured",
            retryable=False,
        )
    payload = {
        "grant_type": "authorization_code",
        "client_id": settings.ZOOMINFO_CLIENT_ID,
        "client_secret": settings.ZOOMINFO_CLIENT_SECRET,
        "redirect_uri": redirect_uri,
        "code": code,
    }
    if code_verifier:
        payload["code_verifier"] = code_verifier
    return await _post_token(payload)


async def refresh_access_token(refresh_token: str) -> Dict[str, Any]:
    if not settings.ZOOMINFO_CLIENT_ID or not settings.ZOOMINFO_CLIENT_SECRET:
        raise ZoomInfoOAuthError(
            "ZoomInfo OAuth is not configured on the server.",
            status_code=503,
            code="zoominfo_oauth_not_configured",
            retryable=False,
        )
    return await _post_token(
        {
            "grant_type": "refresh_token",
            "client_id": settings.ZOOMINFO_CLIENT_ID,
            "client_secret": settings.ZOOMINFO_CLIENT_SECRET,
            "refresh_token": refresh_token,
        }
    )


async def revoke_token(token: str) -> None:
    revoke_url = _revoke_url()
    if not revoke_url:
        return
    timeout = httpx.Timeout(20.0)
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            await client.post(revoke_url, data={"token": token})
    except Exception as exc:  # pragma: no cover - best effort
        logger.warning("ZoomInfo token revoke failed: %s", sanitize_zoominfo_error_text(str(exc)))


async def fetch_zoominfo_identity(access_token: str) -> Dict[str, Any]:
    """Best-effort identity lookup; failures are non-fatal for OAuth completion."""
    userinfo_url = _userinfo_url()
    if not userinfo_url:
        return {}
    timeout = httpx.Timeout(20.0)
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.get(
                userinfo_url,
                headers={"Authorization": f"Bearer {access_token}"},
            )
    except Exception as exc:  # pragma: no cover - network edge cases
        logger.warning("ZoomInfo identity lookup failed: %s", sanitize_zoominfo_error_text(str(exc)))
        return {}

    if not response.is_success:
        return {}

    try:
        payload = response.json()
    except ValueError:
        return {}

    if not isinstance(payload, dict):
        return {}

    user = payload.get("user")
    if not isinstance(user, dict):
        user = {}
    org = payload.get("org")
    if not isinstance(org, dict):
        org = {}

    return {
        "email": payload.get("email") or user.get("email"),
        "name": payload.get("name") or user.get("name"),
        "org_id": payload.get("org_id") or payload.get("tenant_id") or org.get("id"),
        "org_name": payload.get("org_name") or org.get("name"),
        "user_id": payload.get("user_id") or user.get("id") or payload.get("sub"),
    }


def _build_metadata(tokens: Dict[str, Any], previous: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    now = _now_utc()
    expires_in_raw = tokens.get("expires_in")
    try:
        expires_in = int(expires_in_raw) if expires_in_raw is not None else 3600
    except (TypeError, ValueError):
        expires_in = 3600
    metadata = dict(previous or {})
    metadata.update(
        {
            "token_type": tokens.get("token_type"),
            "scope": tokens.get("scope"),
            "expires_in": expires_in,
            "expires_at": (now + timedelta(seconds=expires_in)).isoformat(),
            "updated_at": now.isoformat(),
            "last_refresh_status": "ok",
        }
    )
    return metadata


async def store_zoominfo_tokens(
    db: AsyncSession,
    *,
    account_id: str,
    tokens: Dict[str, Any],
    created_by: Optional[str] = None,
    metadata_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    secret_backend = get_saas_secret_backend()
    previous_metadata = await get_zoominfo_metadata(db, account_id)
    metadata = _build_metadata(tokens, previous=previous_metadata)
    if metadata_overrides:
        metadata.update(metadata_overrides)

    access_token = str(tokens.get("access_token") or "")
    refresh_token = str(tokens.get("refresh_token") or "")
    if not access_token:
        raise ZoomInfoOAuthError(
            "ZoomInfo token response missing access token.",
            status_code=502,
            code="zoominfo_missing_access_token",
            retryable=False,
        )

    await secret_backend.set_secret(
        db=db,
        integration=ZOOMINFO_INTEGRATION_KEY,
        key=ZOOMINFO_ACCESS_TOKEN_KEY,
        value=access_token,
        workspace_id=account_id,
        created_by=created_by,
        validated_at=_now_utc(),
    )

    if refresh_token:
        await secret_backend.set_secret(
            db=db,
            integration=ZOOMINFO_INTEGRATION_KEY,
            key=ZOOMINFO_REFRESH_TOKEN_KEY,
            value=refresh_token,
            workspace_id=account_id,
            created_by=created_by,
        )

    await secret_backend.set_secret(
        db=db,
        integration=ZOOMINFO_INTEGRATION_KEY,
        key=ZOOMINFO_METADATA_KEY,
        value=json.dumps(metadata, separators=(",", ":"), sort_keys=True),
        workspace_id=account_id,
        created_by=created_by,
        validated_at=_now_utc(),
    )

    return metadata


async def get_zoominfo_metadata(db: AsyncSession, account_id: str) -> Dict[str, Any]:
    secret_backend = get_saas_secret_backend()
    metadata_secret = await secret_backend.get_secret(
        db=db,
        integration=ZOOMINFO_INTEGRATION_KEY,
        key=ZOOMINFO_METADATA_KEY,
        workspace_id=account_id,
    )
    if not metadata_secret:
        return {}
    try:
        loaded = json.loads(metadata_secret.value)
        return loaded if isinstance(loaded, dict) else {}
    except json.JSONDecodeError:
        return {}


async def get_zoominfo_secret(
    db: AsyncSession,
    account_id: str,
    key: str,
) -> Optional[str]:
    secret_backend = get_saas_secret_backend()
    secret = await secret_backend.get_secret(
        db=db,
        integration=ZOOMINFO_INTEGRATION_KEY,
        key=key,
        workspace_id=account_id,
    )
    if not secret:
        return None
    return secret.value


async def refresh_if_needed(
    db: AsyncSession,
    *,
    account_id: str,
    force: bool = False,
    created_by: Optional[str] = "system",
) -> Dict[str, Any]:
    metadata = await get_zoominfo_metadata(db, account_id)
    access_token = await get_zoominfo_secret(db, account_id, ZOOMINFO_ACCESS_TOKEN_KEY)
    refresh_token = await get_zoominfo_secret(db, account_id, ZOOMINFO_REFRESH_TOKEN_KEY)

    if not access_token:
        raise ZoomInfoOAuthError(
            "ZoomInfo is not connected.",
            status_code=404,
            code="zoominfo_not_connected",
            retryable=False,
        )

    expires_at = _clean_iso(str(metadata.get("expires_at") or ""))
    needs_refresh = force
    if not needs_refresh:
        if expires_at is None:
            needs_refresh = bool(refresh_token)
        else:
            needs_refresh = expires_at <= (_now_utc() + timedelta(seconds=TOKEN_REFRESH_SKEW_SECONDS))

    if not needs_refresh:
        return metadata

    if not refresh_token:
        raise ZoomInfoOAuthError(
            "ZoomInfo refresh token is missing. Reconnect required.",
            status_code=401,
            code="zoominfo_refresh_missing",
            retryable=False,
        )

    tokens = await refresh_access_token(refresh_token)
    return await store_zoominfo_tokens(
        db,
        account_id=account_id,
        tokens=tokens,
        created_by=created_by,
        metadata_overrides={"refreshed_at": _now_utc().isoformat()},
    )


async def disconnect_zoominfo(db: AsyncSession, account_id: str) -> None:
    secret_backend = get_saas_secret_backend()
    access_token = await get_zoominfo_secret(db, account_id, ZOOMINFO_ACCESS_TOKEN_KEY)
    refresh_token = await get_zoominfo_secret(db, account_id, ZOOMINFO_REFRESH_TOKEN_KEY)

    if access_token:
        await revoke_token(access_token)
    if refresh_token and refresh_token != access_token:
        await revoke_token(refresh_token)

    for key in (ZOOMINFO_ACCESS_TOKEN_KEY, ZOOMINFO_REFRESH_TOKEN_KEY, ZOOMINFO_METADATA_KEY):
        await secret_backend.delete_secret(
            db=db,
            integration=ZOOMINFO_INTEGRATION_KEY,
            key=key,
            workspace_id=account_id,
        )


async def get_zoominfo_status(db: AsyncSession, account_id: str) -> Dict[str, Any]:
    access_token = await get_zoominfo_secret(db, account_id, ZOOMINFO_ACCESS_TOKEN_KEY)
    if not access_token:
        return {"connected": False, "status": "disconnected"}

    metadata = await get_zoominfo_metadata(db, account_id)
    expires_at = _clean_iso(str(metadata.get("expires_at") or ""))
    expires_in_seconds: Optional[int] = None
    needs_refresh = False
    if expires_at is not None:
        expires_in_seconds = int((expires_at - _now_utc()).total_seconds())
        needs_refresh = expires_in_seconds <= TOKEN_REFRESH_SKEW_SECONDS

    status = {
        "connected": True,
        "status": "connected",
        "needs_refresh": needs_refresh,
        "expires_at": expires_at.isoformat() if expires_at else None,
        "expires_in_seconds": expires_in_seconds,
        "scope": metadata.get("scope"),
        "org_id": metadata.get("org_id"),
        "org_name": metadata.get("org_name"),
        "user_id": metadata.get("user_id"),
        "email": metadata.get("email"),
        "last_refresh_status": metadata.get("last_refresh_status"),
        "last_error_code": metadata.get("last_error_code"),
        "last_error_message": metadata.get("last_error_message"),
    }
    return status
