"""Save-time credential checks.

Each connector is checked the way that provider actually authenticates.
There is no shared ``/health`` call: Google Analytics, Tag Manager, and the
other measurement pixels do not expose one, and posting a test event would
record data. Providers that have a read-only account call use that instead.
"""

from __future__ import annotations

import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse

import httpx

from src.core.exceptions import ValidationError

Config = dict[str, Any]
Secrets = dict[str, str]
Probe = Callable[[Config, Secrets, httpx.AsyncClient], Awaitable[None]]

_MEASUREMENT_ID = re.compile(r"^G-[A-Z0-9]{4,20}$", re.IGNORECASE)
_GTM_CONTAINER = re.compile(r"^GTM-[A-Z0-9]+$", re.IGNORECASE)
_CLARITY_PROJECT = re.compile(r"^[A-Za-z0-9]{4,32}$")


@dataclass(frozen=True)
class CredentialCheck:
    mode: str
    note: str
    verify: Probe | None = None


def _text(value: Any) -> str:
    return str(value or "").strip()


def _reject(provider: str, response: httpx.Response) -> None:
    if response.status_code in (401, 403):
        raise ValidationError(f"{provider} rejected these credentials")
    if response.status_code >= 400:
        raise ValidationError(
            f"{provider} could not verify these credentials (HTTP {response.status_code})"
        )


async def _request(
    client: httpx.AsyncClient, method: str, url: str, **kwargs: Any
) -> httpx.Response:
    try:
        return await client.request(method, url, **kwargs)
    except httpx.HTTPError as exc:
        raise ValidationError("Could not reach the provider to verify these credentials") from exc


def _require_https(url: str, label: str) -> None:
    parsed = urlparse(url)
    if parsed.scheme != "https" or not parsed.netloc:
        raise ValidationError(f"{label} must be an https URL")


async def _check_google_analytics(
    config: Config, _secrets: Secrets, _client: httpx.AsyncClient
) -> None:
    measurement_id = _text(config.get("measurement_id"))
    if measurement_id and not _MEASUREMENT_ID.fullmatch(measurement_id):
        raise ValidationError("Measurement ID must look like G-XXXXXXXXXX")


async def _check_google_tag_manager(
    config: Config, _secrets: Secrets, _client: httpx.AsyncClient
) -> None:
    container_id = _text(config.get("container_id"))
    if container_id and not _GTM_CONTAINER.fullmatch(container_id):
        raise ValidationError("Container ID must look like GTM-XXXXXXX")


async def _check_microsoft_clarity(
    config: Config, _secrets: Secrets, _client: httpx.AsyncClient
) -> None:
    project_id = _text(config.get("project_id"))
    if project_id and not _CLARITY_PROJECT.fullmatch(project_id):
        raise ValidationError("Clarity project ID must be the ID from the Clarity project settings")


async def _check_webhook(config: Config, _secrets: Secrets, _client: httpx.AsyncClient) -> None:
    url = _text(config.get("webhook_url"))
    if url:
        _require_https(url, "Webhook URL")


async def _check_stripe(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("secret_key"))
    if not key:
        return
    headers = {"Authorization": f"Bearer {key}"}
    response = await _request(client, "GET", "https://api.stripe.com/v1/account", headers=headers)
    _reject("Stripe", response)
    account_id = _text(config.get("account_id"))
    if not account_id:
        return
    connected = await _request(
        client,
        "GET",
        "https://api.stripe.com/v1/account",
        headers={**headers, "Stripe-Account": account_id},
    )
    _reject("Stripe connected account", connected)


async def _check_twilio(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    sid = _text(config.get("account_sid"))
    token = _text(secrets.get("auth_token"))
    if not sid or not token:
        return
    response = await _request(
        client,
        "GET",
        f"https://api.twilio.com/2010-04-01/Accounts/{sid}.json",
        auth=(sid, token),
    )
    _reject("Twilio", response)


async def _check_openai(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("api_key"))
    if not key:
        return
    headers = {"Authorization": f"Bearer {key}"}
    organization = _text(config.get("organization"))
    if organization:
        headers["OpenAI-Organization"] = organization
    response = await _request(client, "GET", "https://api.openai.com/v1/models", headers=headers)
    _reject("OpenAI", response)


async def _check_resend(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("api_key"))
    if not key:
        return
    response = await _request(
        client,
        "GET",
        "https://api.resend.com/domains",
        headers={"Authorization": f"Bearer {key}"},
    )
    _reject("Resend", response)


async def _check_mailgun(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("api_key"))
    if not key:
        return
    host = (
        "api.eu.mailgun.net" if _text(config.get("region")).lower() == "eu" else "api.mailgun.net"
    )
    response = await _request(
        client,
        "GET",
        f"https://{host}/v3/domains",
        auth=("api", key),
    )
    _reject("Mailgun", response)


async def _check_slack(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("bot_token"))
    if not token:
        return
    response = await _request(
        client,
        "POST",
        "https://slack.com/api/auth.test",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Slack", response)
    try:
        body = response.json()
    except ValueError as exc:
        raise ValidationError("Slack could not verify this bot token") from exc
    if not body.get("ok"):
        raise ValidationError("Slack rejected this bot token")


async def _check_paypal(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    client_id = _text(secrets.get("client_id"))
    client_secret = _text(secrets.get("client_secret"))
    if not client_id or not client_secret:
        return
    host = (
        "api-m.sandbox.paypal.com"
        if _text(config.get("environment")).lower() == "sandbox"
        else "api-m.paypal.com"
    )
    response = await _request(
        client,
        "POST",
        f"https://{host}/v1/oauth2/token",
        auth=(client_id, client_secret),
        data={"grant_type": "client_credentials"},
    )
    _reject("PayPal", response)


async def _check_calendly(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://api.calendly.com/users/me",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Calendly", response)


async def _check_hubspot(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://api.hubapi.com/account-info/v3/details",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("HubSpot", response)


async def _check_salesforce(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    instance = _text(config.get("instance_url")).rstrip("/")
    if not token or not instance:
        return
    response = await _request(
        client,
        "GET",
        f"{instance}/services/data/",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Salesforce", response)


async def _check_facebook(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token") or secrets.get("page_access_token"))
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://graph.facebook.com/me",
        params={"fields": "id"},
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Meta", response)


async def _check_instagram(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://graph.facebook.com/me",
        params={"fields": "id"},
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Instagram", response)


async def _check_x_ads(_config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://api.x.com/2/users/me",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("X", response)


async def _check_google_ads(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        token = await _refresh_google_access_token(config, secrets, client)
        if token:
            secrets["access_token"] = token
    developer_token = _text(secrets.get("developer_token"))
    if not token or not developer_token:
        return
    response = await _request(
        client,
        "GET",
        "https://googleads.googleapis.com/v19/customers:listAccessibleCustomers",
        headers={"Authorization": f"Bearer {token}", "developer-token": developer_token},
    )
    _reject("Google Ads", response)


async def _refresh_google_access_token(
    config: Config, secrets: Secrets, client: httpx.AsyncClient
) -> str:
    refresh = _text(secrets.get("refresh_token"))
    client_id = _text(config.get("client_id") or secrets.get("client_id"))
    client_secret = _text(secrets.get("client_secret"))
    if not refresh or not client_id or not client_secret:
        return ""
    response = await _request(
        client,
        "POST",
        "https://oauth2.googleapis.com/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh,
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    _reject("Google", response)
    try:
        token = response.json().get("access_token")
    except ValueError as exc:
        raise ValidationError("Google could not verify these credentials") from exc
    return token if isinstance(token, str) else ""


async def _check_google_sheets(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    await _check_google_resource(
        config,
        secrets,
        client,
        "spreadsheet_id",
        "https://sheets.googleapis.com/v4/spreadsheets/{resource}?fields=spreadsheetId",
        "Google Sheets",
    )


async def _check_google_docs(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    await _check_google_resource(
        config,
        secrets,
        client,
        "document_id",
        "https://docs.googleapis.com/v1/documents/{resource}?fields=documentId",
        "Google Docs",
    )


async def _check_google_calendar(
    config: Config, secrets: Secrets, client: httpx.AsyncClient
) -> None:
    token = _text(secrets.get("access_token"))
    if not token:
        token = await _refresh_google_access_token(config, secrets, client)
        if token:
            secrets["access_token"] = token
    if not token:
        return
    response = await _request(
        client,
        "GET",
        "https://www.googleapis.com/calendar/v3/users/me/calendarList",
        params={"maxResults": "1"},
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Google Calendar", response)


async def _check_google_resource(
    config: Config,
    secrets: Secrets,
    client: httpx.AsyncClient,
    resource_key: str,
    url_template: str,
    provider: str,
) -> None:
    resource = _text(config.get(resource_key))
    token = _text(secrets.get("access_token"))
    if not token:
        token = await _refresh_google_access_token(config, secrets, client)
        if token:
            secrets["access_token"] = token
    if not resource or not token:
        return
    response = await _request(
        client,
        "GET",
        url_template.format(resource=resource),
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject(provider, response)


async def _check_cloudflare_r2(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    account_id = _text(config.get("account_id"))
    token = _text(secrets.get("cloudflare_api_token"))
    if not account_id or not token:
        return
    response = await _request(
        client,
        "GET",
        f"https://api.cloudflare.com/client/v4/accounts/{account_id}/r2/buckets",
        headers={"Authorization": f"Bearer {token}"},
    )
    _reject("Cloudflare", response)
    try:
        body = response.json()
    except ValueError as exc:
        raise ValidationError("Cloudflare could not verify this API token") from exc
    if body.get("success") is False:
        raise ValidationError("Cloudflare rejected this API token for the account")


async def _check_easypost(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("api_key"))
    if not key:
        return
    base = _text(config.get("api_base_url")) or "https://api.easypost.com/v2"
    response = await _request(client, "GET", f"{base.rstrip('/')}/api_keys", auth=(key, ""))
    _reject("EasyPost", response)


async def _check_accredible(config: Config, secrets: Secrets, client: httpx.AsyncClient) -> None:
    key = _text(secrets.get("api_key"))
    if not key:
        return
    base = _text(config.get("api_base_url")) or "https://api.accredible.com/v1"
    response = await _request(
        client,
        "GET",
        f"{base.rstrip('/')}/credentials",
        params={"limit": "1"},
        headers={"Authorization": f"Token token={key}"},
    )
    _reject("Accredible", response)


_FIELDS = "Saving checks that the required fields for this connector are present."

CREDENTIAL_CHECKS: dict[str, CredentialCheck] = {
    "google_analytics": CredentialCheck(
        "format",
        "Saving checks the Measurement ID shape. Analytics has no account health endpoint.",
        _check_google_analytics,
    ),
    "google_tag_manager": CredentialCheck(
        "format",
        "Saving checks the container ID. Tag Manager has no credential health call.",
        _check_google_tag_manager,
    ),
    "microsoft_clarity": CredentialCheck(
        "format",
        "Saving checks the project ID. Clarity has no credential health call.",
        _check_microsoft_clarity,
    ),
    "tiktok": CredentialCheck(
        "fields",
        "TikTok has no health endpoint. The pixel and token are sent with each event.",
    ),
    "snapchat": CredentialCheck(
        "fields",
        "Snap has no health endpoint. The pixel and token are sent with each event.",
    ),
    "microsoft_ads": CredentialCheck(
        "fields",
        "Microsoft Advertising has no health URL. The developer token is sent on upload.",
    ),
    "ses": CredentialCheck(
        "fields",
        "Amazon SES has no health URL. The key and region are used when email is sent.",
    ),
    "amazon_s3": CredentialCheck(
        "fields",
        "Amazon S3 has no health URL. The key and region are used for object requests.",
    ),
    "linkedin": CredentialCheck(
        "fields",
        "LinkedIn has no health endpoint. The token is sent when a post is published.",
    ),
    "make_com": CredentialCheck(
        "webhook_url",
        "Saving checks the webhook is https. The scenario is not called on save.",
        _check_webhook,
    ),
    "n8n": CredentialCheck(
        "webhook_url",
        "Saving checks the webhook is https. The workflow is not called on save.",
        _check_webhook,
    ),
    "stripe": CredentialCheck(
        "provider_read",
        "Saving checks the secret key against your Stripe account.",
        _check_stripe,
    ),
    "twilio": CredentialCheck(
        "provider_read",
        "Saving checks the Account SID and auth token with Twilio.",
        _check_twilio,
    ),
    "openai": CredentialCheck(
        "provider_read",
        "Saving checks the API key with OpenAI.",
        _check_openai,
    ),
    "resend": CredentialCheck(
        "provider_read",
        "Saving checks the API key with Resend.",
        _check_resend,
    ),
    "mailgun": CredentialCheck(
        "provider_read",
        "Saving checks the API key with Mailgun.",
        _check_mailgun,
    ),
    "slack": CredentialCheck(
        "provider_read",
        "Saving checks the bot token with Slack auth.test.",
        _check_slack,
    ),
    "paypal": CredentialCheck(
        "provider_read",
        "Saving checks the client ID and secret with PayPal.",
        _check_paypal,
    ),
    "calendly": CredentialCheck(
        "provider_read",
        "Saving checks the access token with Calendly.",
        _check_calendly,
    ),
    "hubspot": CredentialCheck(
        "provider_read",
        "Saving checks the access token with your HubSpot account.",
        _check_hubspot,
    ),
    "salesforce": CredentialCheck(
        "provider_read",
        "Saving checks the access token against the Salesforce instance URL.",
        _check_salesforce,
    ),
    "facebook": CredentialCheck(
        "provider_read",
        "Saving checks the access token with Meta. No test event is sent.",
        _check_facebook,
    ),
    "instagram": CredentialCheck(
        "provider_read",
        "Saving checks the access token with Meta. No post is published.",
        _check_instagram,
    ),
    "x_ads": CredentialCheck(
        "provider_read",
        "Saving checks the bearer token with X. Conversions are not sent.",
        _check_x_ads,
    ),
    "google_ads": CredentialCheck(
        "provider_read",
        "Saving checks the developer token with Ads. No conversion is uploaded.",
        _check_google_ads,
    ),
    "google_sheets": CredentialCheck(
        "provider_read",
        "Saving checks the Google token against the spreadsheet when a spreadsheet ID is set.",
        _check_google_sheets,
    ),
    "google_docs": CredentialCheck(
        "provider_read",
        "Saving checks the Google token against the document when a document ID is set.",
        _check_google_docs,
    ),
    "google_calendar": CredentialCheck(
        "provider_read",
        "Saving checks the Google token against Calendar.",
        _check_google_calendar,
    ),
    "cloudflare_r2": CredentialCheck(
        "provider_read",
        "Saving checks the API token against this account's R2 buckets.",
        _check_cloudflare_r2,
    ),
    "easypost": CredentialCheck(
        "provider_read",
        "Saving checks the API key with EasyPost.",
        _check_easypost,
    ),
    "accredible": CredentialCheck(
        "provider_read",
        "Saving checks the API key with Accredible.",
        _check_accredible,
    ),
}


def credential_check_for(connector_key: str) -> CredentialCheck:
    return CREDENTIAL_CHECKS.get(
        connector_key,
        CredentialCheck("fields", _FIELDS),
    )


async def verify_connector_credentials(
    connector_key: str,
    config: Config,
    secrets: Secrets,
    *,
    client: httpx.AsyncClient | None = None,
) -> None:
    """Reject credentials the provider will not accept. Never calls ``/health``."""
    spec = CREDENTIAL_CHECKS.get(connector_key)
    if spec is None or spec.verify is None:
        return
    owns_client = client is None
    http = client or httpx.AsyncClient(timeout=12)
    try:
        await spec.verify(config or {}, secrets or {}, http)
    finally:
        if owns_client:
            await http.aclose()
