# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License - see LICENSE file for details

"""
Credential Registry — single source of truth for all integration credentials.

Maps every integration to its credential requirements: env vars, validation
functions, re-auth type, sensitivity level, and documentation links.

Inspired by:
- GNOME Online Accounts (EnsureCredentials + AttentionNeeded)
- Android AccountManager (invalidate-then-retry)
- Zapier (mandatory connection test on save)
- Home Assistant (async_step_reauth as required quality rule)

Usage:
    from familiar.core.credential_registry import (
        get_credential_spec, list_credentials, is_configured, validate,
    )

    spec = get_credential_spec("github")
    if is_configured("github"):
        ok, msg = validate("github")
"""

from __future__ import annotations

import importlib
import logging
import os
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


# =============================================================================
# ENUMS
# =============================================================================


class CredentialType(str, Enum):
    """Type of authentication credential."""

    API_KEY = "api_key"
    OAUTH2 = "oauth2"
    BASIC = "basic"
    BEARER = "bearer"
    TOKEN_PAIR = "token_pair"
    SESSION = "session"
    NONE = "none"


class ReauthType(str, Enum):
    """How re-authorization is performed from the dashboard."""

    SIMPLE_TOKEN = "simple_token"
    OAUTH_REDIRECT = "oauth_redirect"
    MULTI_FIELD = "multi_field"
    WIZARD_ONLY = "wizard_only"
    NONE = "none"


class Sensitivity(str, Enum):
    """Credential sensitivity tier (maps to check intervals)."""

    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


# =============================================================================
# DATA CLASS
# =============================================================================


@dataclass
class CredentialSpec:
    """Declaration of what an integration needs for authentication."""

    service_key: str
    display_name: str
    category: str  # llm | integrations | apis | social | selfhosted | channels | security | mesh
    credential_type: CredentialType
    env_vars: list[str]
    validate_fn: Optional[str]  # dotted path to validation function
    refresh_fn: Optional[str]  # dotted path to silent refresh function
    reauth_type: ReauthType
    doc_url: str
    doc_hint: str
    sensitivity: Sensitivity
    check_interval: int  # seconds between periodic checks, 0 = on-demand
    scopes: list[str] = field(default_factory=list)
    required_packages: list[str] = field(default_factory=list)

    def to_dict(self) -> dict:
        return {
            "service_key": self.service_key,
            "display_name": self.display_name,
            "category": self.category,
            "credential_type": self.credential_type.value,
            "env_vars": self.env_vars,
            "has_validate": self.validate_fn is not None,
            "has_refresh": self.refresh_fn is not None,
            "reauth_type": self.reauth_type.value,
            "doc_url": self.doc_url,
            "doc_hint": self.doc_hint,
            "sensitivity": self.sensitivity.value,
            "check_interval": self.check_interval,
            "scopes": self.scopes,
            "required_packages": self.required_packages,
        }


# =============================================================================
# LAZY IMPORT PROXIES
# =============================================================================


def _http_test(
    url: str,
    *,
    headers: Optional[dict] = None,
    params: Optional[dict] = None,
    auth: Optional[tuple] = None,
    method: str = "GET",
) -> tuple:
    """Proxy to _test_http_service. Lazy import avoids channel deps at load time."""
    from familiar.channels.connect_wizard._helpers import _test_http_service

    return _test_http_service(url, headers=headers, params=params, auth=auth, method=method)


def _imap_test(env_vars: dict) -> tuple:
    """Proxy to _test_imap_connection."""
    from familiar.channels.connect_wizard._helpers import _test_imap_connection

    return _test_imap_connection(env_vars)


# =============================================================================
# STANDALONE VALIDATE FUNCTIONS
# =============================================================================


# ── LLM Providers ────────────────────────────────────────────────────────────


def validate_anthropic() -> tuple:
    api_key = os.environ.get("ANTHROPIC_API_KEY", "")
    if not api_key:
        return False, "ANTHROPIC_API_KEY not set"
    try:
        import anthropic

        client = anthropic.Anthropic(api_key=api_key)
        client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=1,
            messages=[{"role": "user", "content": "ping"}],
        )
        return True, "Authenticated"
    except ImportError:
        return False, "anthropic package not installed"
    except Exception as e:
        return False, str(e)[:200]


def validate_openai() -> tuple:
    api_key = os.environ.get("OPENAI_API_KEY", "")
    if not api_key:
        return False, "OPENAI_API_KEY not set"
    try:
        import openai

        client = openai.OpenAI(api_key=api_key)
        client.models.list()
        return True, "Authenticated"
    except ImportError:
        return False, "openai package not installed"
    except Exception as e:
        return False, str(e)[:200]


def validate_gemini() -> tuple:
    api_key = os.environ.get("GEMINI_API_KEY", "") or os.environ.get("GOOGLE_API_KEY", "")
    if not api_key:
        return False, "GEMINI_API_KEY not set"
    try:
        import openai

        client = openai.OpenAI(
            api_key=api_key,
            base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
        )
        client.models.list()
        return True, "Authenticated"
    except ImportError:
        return False, "openai package not installed"
    except Exception as e:
        return False, str(e)[:200]


def validate_ollama() -> tuple:
    base_url = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
    return _http_test(f"{base_url}/api/tags")


# ── Integrations ─────────────────────────────────────────────────────────────


def validate_google_workspace() -> tuple:
    data_dir = Path.home() / ".familiar" / "data"
    token_files = {
        "Calendar": data_dir / "google_token.json",
        "Drive": data_dir / "google_token_drive.json",
        "Contacts": data_dir / "google_token_contacts.json",
        "Gmail": data_dir / "google_token_gmail.json",
    }
    found = [name for name, path in token_files.items() if path.exists()]
    if not found:
        creds = data_dir / "google_credentials.json"
        if not creds.exists():
            return False, "No Google OAuth credentials or tokens found"
        return False, "Credentials file found but no tokens — run /connect google"
    return True, f"Tokens: {', '.join(found)}"


def validate_email() -> tuple:
    required = ["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_SERVER", "EMAIL_IMAP_PORT"]
    env_vars = {k: os.environ.get(k, "") for k in required}
    missing = [k for k, v in env_vars.items() if not v]
    if missing:
        return False, f"Missing: {', '.join(missing)}"
    return _imap_test(env_vars)


def validate_github() -> tuple:
    token = os.environ.get("GITHUB_TOKEN", "")
    if not token:
        return False, "GITHUB_TOKEN not set"
    return _http_test(
        "https://api.github.com/user",
        headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"},
    )


def validate_slack() -> tuple:
    bot_token = os.environ.get("SLACK_BOT_TOKEN", "")
    if not bot_token:
        return False, "SLACK_BOT_TOKEN not set"
    app_token = os.environ.get("SLACK_APP_TOKEN", "")
    if not app_token:
        return False, "SLACK_APP_TOKEN not set"
    return _http_test(
        "https://slack.com/api/auth.test",
        headers={"Authorization": f"Bearer {bot_token}"},
        method="POST",
    )


def validate_sms() -> tuple:
    required = ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"]
    missing = [k for k in required if not os.environ.get(k)]
    if missing:
        return False, f"Missing: {', '.join(missing)}"
    sid = os.environ["TWILIO_ACCOUNT_SID"]
    token = os.environ["TWILIO_AUTH_TOKEN"]
    return _http_test(
        f"https://api.twilio.com/2010-04-01/Accounts/{sid}.json",
        auth=(sid, token),
    )


def validate_websearch() -> tuple:
    return True, "DuckDuckGo available (no credentials needed)"


# ── API Services ─────────────────────────────────────────────────────────────


def validate_hubspot() -> tuple:
    token = os.environ.get("HUBSPOT_ACCESS_TOKEN", "")
    if not token:
        return False, "HUBSPOT_ACCESS_TOKEN not set"
    return _http_test(
        "https://api.hubapi.com/crm/v3/objects/contacts",
        headers={"Authorization": f"Bearer {token}"},
        params={"limit": "1"},
    )


def validate_todoist() -> tuple:
    token = os.environ.get("TODOIST_API_TOKEN", "")
    if not token:
        return False, "TODOIST_API_TOKEN not set"
    return _http_test(
        "https://api.todoist.com/rest/v2/projects",
        headers={"Authorization": f"Bearer {token}"},
    )


def validate_stripe() -> tuple:
    key = os.environ.get("STRIPE_SECRET_KEY", "")
    if not key:
        return False, "STRIPE_SECRET_KEY not set"
    return _http_test(
        "https://api.stripe.com/v1/balance",
        headers={"Authorization": f"Bearer {key}"},
    )


def validate_square() -> tuple:
    token = os.environ.get("SQUARE_ACCESS_TOKEN", "")
    if not token:
        return False, "SQUARE_ACCESS_TOKEN not set"
    return _http_test(
        "https://connect.squareup.com/v2/locations",
        headers={"Authorization": f"Bearer {token}"},
    )


def validate_mailerlite() -> tuple:
    key = os.environ.get("MAILERLITE_API_KEY", "")
    if not key:
        return False, "MAILERLITE_API_KEY not set"
    return _http_test(
        "https://connect.mailerlite.com/api/subscribers",
        headers={"Authorization": f"Bearer {key}"},
        params={"limit": "1"},
    )


def validate_shippo() -> tuple:
    key = os.environ.get("SHIPPO_API_KEY", "")
    if not key:
        return False, "SHIPPO_API_KEY not set"
    return _http_test(
        "https://api.goshippo.com/carriers",
        headers={"Authorization": f"ShippoToken {key}"},
    )


def validate_calcom() -> tuple:
    key = os.environ.get("CALCOM_API_KEY", "")
    if not key:
        return False, "CALCOM_API_KEY not set"
    url = os.environ.get("CALCOM_URL", "https://api.cal.com").rstrip("/")
    return _http_test(f"{url}/v2/me", headers={"Authorization": f"Bearer {key}"})


def validate_brave_search() -> tuple:
    key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
    if not key:
        return False, "BRAVE_SEARCH_API_KEY not set"
    return _http_test(
        "https://api.search.brave.com/res/v1/web/search",
        headers={"X-Subscription-Token": key},
        params={"q": "test", "count": "1"},
    )


def validate_spoonacular() -> tuple:
    key = os.environ.get("SPOONACULAR_API_KEY", "")
    if not key:
        return False, "SPOONACULAR_API_KEY not set"
    return _http_test(
        "https://api.spoonacular.com/recipes/random",
        params={"number": "1", "apiKey": key},
    )


def validate_reddit() -> tuple:
    client_id = os.environ.get("REDDIT_CLIENT_ID", "")
    client_secret = os.environ.get("REDDIT_CLIENT_SECRET", "")
    if not client_id or not client_secret:
        return False, "REDDIT_CLIENT_ID or REDDIT_CLIENT_SECRET not set"
    try:
        import httpx

        resp = httpx.post(
            "https://www.reddit.com/api/v1/access_token",
            auth=(client_id, client_secret),
            data={"grant_type": "client_credentials"},
            headers={"User-Agent": "familiar-agent/1.0"},
            timeout=10,
        )
        if resp.status_code == 200 and "access_token" in resp.json():
            return True, "Authenticated"
        return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
    except Exception as e:
        return False, str(e)[:200]


def validate_etsy() -> tuple:
    key = os.environ.get("ETSY_API_KEY", "")
    shop_id = os.environ.get("ETSY_SHOP_ID", "")
    if not key or not shop_id:
        return False, "ETSY_API_KEY or ETSY_SHOP_ID not set"
    return _http_test(
        f"https://openapi.etsy.com/v3/application/shops/{shop_id}",
        headers={"x-api-key": key},
    )


def validate_clockify() -> tuple:
    key = os.environ.get("CLOCKIFY_API_KEY", "")
    if not key:
        return False, "CLOCKIFY_API_KEY not set"
    return _http_test(
        "https://api.clockify.me/api/v1/user",
        headers={"X-Api-Key": key},
    )


def validate_quickbooks() -> tuple:
    token = os.environ.get("QUICKBOOKS_ACCESS_TOKEN", "")
    realm = os.environ.get("QUICKBOOKS_REALM_ID", "")
    if not token or not realm:
        return False, "QUICKBOOKS_ACCESS_TOKEN or QUICKBOOKS_REALM_ID not set"
    return _http_test(
        f"https://quickbooks.api.intuit.com/v3/company/{realm}/query",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        params={"query": "select * from CompanyInfo"},
    )


# ── Social ───────────────────────────────────────────────────────────────────


def validate_mastodon() -> tuple:
    url = os.environ.get("MASTODON_INSTANCE_URL", "").rstrip("/")
    token = os.environ.get("MASTODON_ACCESS_TOKEN", "")
    if not url or not token:
        return False, "MASTODON_INSTANCE_URL or MASTODON_ACCESS_TOKEN not set"
    return _http_test(
        f"{url}/api/v1/accounts/verify_credentials",
        headers={"Authorization": f"Bearer {token}"},
    )


def validate_bluesky() -> tuple:
    handle = os.environ.get("BLUESKY_HANDLE", "")
    password = os.environ.get("BLUESKY_APP_PASSWORD", "")
    pds_url = os.environ.get("BLUESKY_PDS_URL", "https://bsky.social").rstrip("/")
    if not handle or not password:
        return False, "BLUESKY_HANDLE or BLUESKY_APP_PASSWORD not set"
    try:
        import httpx

        resp = httpx.post(
            f"{pds_url}/xrpc/com.atproto.server.createSession",
            json={"identifier": handle, "password": password},
            timeout=10,
        )
        if resp.status_code == 200:
            return True, "Session created"
        return False, f"HTTP {resp.status_code}: {resp.text[:200]}"
    except Exception as e:
        return False, str(e)[:200]


def validate_instagram() -> tuple:
    token = os.environ.get("INSTAGRAM_ACCESS_TOKEN", "")
    if not token:
        return False, "INSTAGRAM_ACCESS_TOKEN not set"
    return _http_test(
        "https://graph.instagram.com/me",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,username"},
    )


# ── Self-Hosted ──────────────────────────────────────────────────────────────


def validate_jellyfin() -> tuple:
    url = os.environ.get("JELLYFIN_URL", "").rstrip("/")
    token = os.environ.get("JELLYFIN_TOKEN", "")
    if not url or not token:
        return False, "JELLYFIN_URL or JELLYFIN_TOKEN not set"
    return _http_test(f"{url}/Users", headers={"X-Emby-Token": token})


def validate_gitea() -> tuple:
    url = os.environ.get("GITEA_URL", "").rstrip("/")
    token = os.environ.get("GITEA_TOKEN", "")
    if not url or not token:
        return False, "GITEA_URL or GITEA_TOKEN not set"
    return _http_test(
        f"{url}/api/v1/repos/search",
        headers={"Authorization": f"token {token}"},
    )


def validate_homeassistant() -> tuple:
    url = os.environ.get("HOMEASSISTANT_URL", "").rstrip("/")
    token = os.environ.get("HOMEASSISTANT_TOKEN", "")
    if not url or not token:
        return False, "HOMEASSISTANT_URL or HOMEASSISTANT_TOKEN not set"
    return _http_test(f"{url}/api/", headers={"Authorization": f"Bearer {token}"})


def validate_joplin() -> tuple:
    token = os.environ.get("JOPLIN_TOKEN", "")
    if not token:
        return False, "JOPLIN_TOKEN not set"
    url = os.environ.get("JOPLIN_URL", "http://localhost:41184").rstrip("/")
    return _http_test(f"{url}/folders", params={"token": token})


def validate_pihole() -> tuple:
    url = os.environ.get("PIHOLE_URL", "").rstrip("/")
    token = os.environ.get("PIHOLE_TOKEN", "") or os.environ.get("PIHOLE_API_KEY", "")
    pihole_type = os.environ.get("PIHOLE_TYPE", "pihole")
    if not url:
        return False, "PIHOLE_URL not set"
    if pihole_type == "adguard":
        if not token or ":" not in token:
            return False, "PIHOLE_TOKEN not set (format: user:password for AdGuard)"
        user, password = token.split(":", 1)
        return _http_test(f"{url}/control/status", auth=(user, password))
    if not token:
        return False, "PIHOLE_TOKEN not set"
    return _http_test(f"{url}/admin/api.php", params={"status": "", "auth": token})


def validate_nextcloud() -> tuple:
    url = os.environ.get("NEXTCLOUD_URL", "").rstrip("/")
    user = os.environ.get("NEXTCLOUD_USER", "")
    token = os.environ.get("NEXTCLOUD_TOKEN", "")
    if not url or not user or not token:
        return False, "NEXTCLOUD_URL, NEXTCLOUD_USER, or NEXTCLOUD_TOKEN not set"
    return _http_test(
        f"{url}/remote.php/dav/files/{user}/",
        auth=(user, token),
        method="PROPFIND",
    )


def validate_mealie() -> tuple:
    url = os.environ.get("MEALIE_URL", "").rstrip("/")
    token = os.environ.get("MEALIE_TOKEN", "")
    if not url or not token:
        return False, "MEALIE_URL or MEALIE_TOKEN not set"
    return _http_test(
        f"{url}/api/recipes",
        headers={"Authorization": f"Bearer {token}"},
        params={"page": "1", "perPage": "1"},
    )


def validate_vaultwarden() -> tuple:
    url = os.environ.get("VAULTWARDEN_URL", "").rstrip("/")
    token = os.environ.get("VAULTWARDEN_TOKEN", "")
    if not url:
        return False, "VAULTWARDEN_URL not set"
    if not token:
        return False, "VAULTWARDEN_TOKEN not set"
    return _http_test(f"{url}/api/alive")


# ── Channels ─────────────────────────────────────────────────────────────────


def validate_telegram() -> tuple:
    token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
    if not token:
        return False, "TELEGRAM_BOT_TOKEN not set"
    return _http_test(f"https://api.telegram.org/bot{token}/getMe")


def validate_discord() -> tuple:
    token = os.environ.get("DISCORD_BOT_TOKEN", "")
    if not token:
        return False, "DISCORD_BOT_TOKEN not set"
    return _http_test(
        "https://discord.com/api/v10/users/@me",
        headers={"Authorization": f"Bot {token}"},
    )


# ── Security ─────────────────────────────────────────────────────────────────


def validate_encryption() -> tuple:
    key = os.environ.get("FAMILIAR_ENCRYPTION_KEY", "")
    if not key:
        return False, "FAMILIAR_ENCRYPTION_KEY not set"
    try:
        from cryptography.fernet import Fernet

        f = Fernet(key.encode() if isinstance(key, str) else key)
        test = f.encrypt(b"test")
        result = f.decrypt(test)
        if result == b"test":
            return True, "Encryption key valid"
        return False, "Round-trip failed"
    except ImportError:
        return False, "cryptography package not installed"
    except Exception as e:
        return False, f"Invalid key: {e}"


def validate_dashboard_key() -> tuple:
    key = os.environ.get("FAMILIAR_DASHBOARD_KEY", "")
    if not key:
        return False, "FAMILIAR_DASHBOARD_KEY not set"
    if len(key) < 16:
        return False, "Dashboard key too short (min 16 characters)"
    return True, "Dashboard key configured"


# =============================================================================
# CREDENTIAL REGISTRY
# =============================================================================

_MOD = "familiar.core.credential_registry"

CREDENTIAL_REGISTRY: dict[str, CredentialSpec] = {
    # ── LLM Providers ────────────────────────────────────────────────────
    "anthropic": CredentialSpec(
        service_key="anthropic",
        display_name="Anthropic (Claude)",
        category="llm",
        credential_type=CredentialType.API_KEY,
        env_vars=["ANTHROPIC_API_KEY"],
        validate_fn=f"{_MOD}.validate_anthropic",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://console.anthropic.com/settings/keys",
        doc_hint="Get your API key from the Anthropic Console",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
        required_packages=["anthropic"],
    ),
    "openai": CredentialSpec(
        service_key="openai",
        display_name="OpenAI (GPT)",
        category="llm",
        credential_type=CredentialType.API_KEY,
        env_vars=["OPENAI_API_KEY"],
        validate_fn=f"{_MOD}.validate_openai",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://platform.openai.com/api-keys",
        doc_hint="Generate an API key in the OpenAI dashboard",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
        required_packages=["openai"],
    ),
    "gemini": CredentialSpec(
        service_key="gemini",
        display_name="Gemini (Google AI)",
        category="llm",
        credential_type=CredentialType.API_KEY,
        env_vars=["GEMINI_API_KEY"],
        validate_fn=f"{_MOD}.validate_gemini",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://aistudio.google.com/apikey",
        doc_hint="Create an API key in Google AI Studio",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
        required_packages=["openai"],
    ),
    "ollama": CredentialSpec(
        service_key="ollama",
        display_name="Ollama (local)",
        category="llm",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=f"{_MOD}.validate_ollama",
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://ollama.com/download",
        doc_hint="Download and install Ollama for local models",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    # ── Integrations ─────────────────────────────────────────────────────
    "google_workspace": CredentialSpec(
        service_key="google_workspace",
        display_name="Google Workspace",
        category="integrations",
        credential_type=CredentialType.OAUTH2,
        env_vars=[],
        validate_fn=f"{_MOD}.validate_google_workspace",
        refresh_fn="familiar.core.credential_health._refresh_google_oauth",
        reauth_type=ReauthType.OAUTH_REDIRECT,
        doc_url="https://console.cloud.google.com/apis/credentials",
        doc_hint="Create OAuth 2.0 credentials (Desktop app type)",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
        scopes=["calendar", "gmail", "drive", "contacts"],
        required_packages=["google-auth", "google-auth-oauthlib", "google-api-python-client"],
    ),
    "email": CredentialSpec(
        service_key="email",
        display_name="Email (IMAP/SMTP)",
        category="integrations",
        credential_type=CredentialType.BASIC,
        env_vars=["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_SERVER", "EMAIL_IMAP_PORT"],
        validate_fn=f"{_MOD}.validate_email",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://myaccount.google.com/apppasswords",
        doc_hint="Use an App Password for Gmail, or your regular password for other providers",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "github": CredentialSpec(
        service_key="github",
        display_name="GitHub (issues & PRs)",
        category="integrations",
        credential_type=CredentialType.API_KEY,
        env_vars=["GITHUB_TOKEN"],
        validate_fn=f"{_MOD}.validate_github",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://github.com/settings/tokens",
        doc_hint="Create a PAT with repo + notifications scopes",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
        scopes=["repo", "notifications"],
        required_packages=["PyGithub"],
    ),
    "slack": CredentialSpec(
        service_key="slack",
        display_name="Slack (Socket Mode)",
        category="integrations",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
        validate_fn=f"{_MOD}.validate_slack",
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="https://api.slack.com/apps",
        doc_hint="Create a Slack App with Socket Mode enabled",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
        required_packages=["slack_sdk"],
    ),
    "sms": CredentialSpec(
        service_key="sms",
        display_name="SMS (Twilio)",
        category="integrations",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"],
        validate_fn=f"{_MOD}.validate_sms",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://www.twilio.com/docs/sms/quickstart/python",
        doc_hint="Get Account SID, Auth Token, and a phone number from Twilio",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
        required_packages=["twilio"],
    ),
    "websearch": CredentialSpec(
        service_key="websearch",
        display_name="WebSearch (DuckDuckGo)",
        category="integrations",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=f"{_MOD}.validate_websearch",
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://docs.searxng.org/admin/installation.html",
        doc_hint="DuckDuckGo works out of the box; SearXNG is optional",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "browser": CredentialSpec(
        service_key="browser",
        display_name="Browser (Playwright)",
        category="integrations",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://playwright.dev/python/docs/intro",
        doc_hint="Install Playwright for browser automation",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
        required_packages=["playwright"],
    ),
    "voice": CredentialSpec(
        service_key="voice",
        display_name="Voice (Whisper)",
        category="integrations",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://github.com/openai/whisper",
        doc_hint="OpenAI Whisper for speech recognition",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
    ),
    "healthcare": CredentialSpec(
        service_key="healthcare",
        display_name="Healthcare / Compliance",
        category="integrations",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://www.hhs.gov/hipaa/index.html",
        doc_hint="Enable HIPAA compliance mode in config",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
    ),
    # ── API Services ─────────────────────────────────────────────────────
    "hubspot": CredentialSpec(
        service_key="hubspot",
        display_name="HubSpot (CRM)",
        category="apis",
        credential_type=CredentialType.BEARER,
        env_vars=["HUBSPOT_ACCESS_TOKEN"],
        validate_fn=f"{_MOD}.validate_hubspot",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://developers.hubspot.com/docs/api/private-apps",
        doc_hint="Create a private app for API access",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "todoist": CredentialSpec(
        service_key="todoist",
        display_name="Todoist (tasks)",
        category="apis",
        credential_type=CredentialType.BEARER,
        env_vars=["TODOIST_API_TOKEN"],
        validate_fn=f"{_MOD}.validate_todoist",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://todoist.com/app/settings/integrations/developer",
        doc_hint="Copy your API token from Developer settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "stripe": CredentialSpec(
        service_key="stripe",
        display_name="Stripe (payments)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["STRIPE_SECRET_KEY"],
        validate_fn=f"{_MOD}.validate_stripe",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://dashboard.stripe.com/apikeys",
        doc_hint="Copy your Secret key from the Stripe Dashboard",
        sensitivity=Sensitivity.CRITICAL,
        check_interval=0,
    ),
    "square": CredentialSpec(
        service_key="square",
        display_name="Square (POS)",
        category="apis",
        credential_type=CredentialType.BEARER,
        env_vars=["SQUARE_ACCESS_TOKEN"],
        validate_fn=f"{_MOD}.validate_square",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://developer.squareup.com/apps",
        doc_hint="Create an app and get Access Token",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
    ),
    "mailerlite": CredentialSpec(
        service_key="mailerlite",
        display_name="MailerLite (email marketing)",
        category="apis",
        credential_type=CredentialType.BEARER,
        env_vars=["MAILERLITE_API_KEY"],
        validate_fn=f"{_MOD}.validate_mailerlite",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://dashboard.mailerlite.com/integrations/api",
        doc_hint="Generate an API key in your MailerLite dashboard",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "shippo": CredentialSpec(
        service_key="shippo",
        display_name="Shippo (shipping)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["SHIPPO_API_KEY"],
        validate_fn=f"{_MOD}.validate_shippo",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://apps.goshippo.com/settings/api",
        doc_hint="Copy your API Live Token from Shippo Settings",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "calcom": CredentialSpec(
        service_key="calcom",
        display_name="Cal.com (scheduling)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["CALCOM_API_KEY"],
        validate_fn=f"{_MOD}.validate_calcom",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://app.cal.com/settings/developer/api-keys",
        doc_hint="Create an API key in Cal.com Developer settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "brave_search": CredentialSpec(
        service_key="brave_search",
        display_name="Brave Search (web search)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["BRAVE_SEARCH_API_KEY"],
        validate_fn=f"{_MOD}.validate_brave_search",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://brave.com/search/api/",
        doc_hint="Subscribe and get API key",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "spoonacular": CredentialSpec(
        service_key="spoonacular",
        display_name="Spoonacular (recipes)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["SPOONACULAR_API_KEY"],
        validate_fn=f"{_MOD}.validate_spoonacular",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://spoonacular.com/food-api/console",
        doc_hint="Sign up for an API key",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "reddit": CredentialSpec(
        service_key="reddit",
        display_name="Reddit (social)",
        category="apis",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"],
        validate_fn=f"{_MOD}.validate_reddit",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://www.reddit.com/prefs/apps",
        doc_hint="Create a script-type application",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "etsy": CredentialSpec(
        service_key="etsy",
        display_name="Etsy (marketplace)",
        category="apis",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["ETSY_API_KEY", "ETSY_SHOP_ID"],
        validate_fn=f"{_MOD}.validate_etsy",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://www.etsy.com/developers/your-apps",
        doc_hint="Create an app for API access",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "clockify": CredentialSpec(
        service_key="clockify",
        display_name="Clockify (time tracking)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["CLOCKIFY_API_KEY"],
        validate_fn=f"{_MOD}.validate_clockify",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://app.clockify.me/user/settings",
        doc_hint="Generate an API key in Clockify Settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "quickbooks": CredentialSpec(
        service_key="quickbooks",
        display_name="QuickBooks (accounting)",
        category="apis",
        credential_type=CredentialType.OAUTH2,
        env_vars=["QUICKBOOKS_ACCESS_TOKEN", "QUICKBOOKS_REALM_ID"],
        validate_fn=f"{_MOD}.validate_quickbooks",
        refresh_fn=None,
        reauth_type=ReauthType.OAUTH_REDIRECT,
        doc_url="https://developer.intuit.com/app/developer/qbo/docs/get-started",
        doc_hint="Create an app for API access",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
    ),
    "google_trends": CredentialSpec(
        service_key="google_trends",
        display_name="Google Trends",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["GOOGLE_TRENDS_API_KEY"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://developers.google.com/trends/",
        doc_hint="Access trending search data",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "news": CredentialSpec(
        service_key="news",
        display_name="News (GNews)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["GNEWS_API_KEY"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://gnews.io/docs/v4",
        doc_hint="Get a free API key for news headlines",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "cloudinary_img": CredentialSpec(
        service_key="cloudinary_img",
        display_name="Cloudinary (images)",
        category="apis",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://cloudinary.com/documentation/admin_api",
        doc_hint="Find Cloud Name and API Key in Dashboard",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "civicrm": CredentialSpec(
        service_key="civicrm",
        display_name="CiviCRM",
        category="apis",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["CIVICRM_URL", "CIVICRM_API_KEY"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://docs.civicrm.org/sysadmin/en/latest/setup/api/",
        doc_hint="Generate an API key in your CiviCRM instance",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "globalgiving": CredentialSpec(
        service_key="globalgiving",
        display_name="GlobalGiving (grants)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["GLOBALGIVING_API_KEY"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://www.globalgiving.org/api/",
        doc_hint="Sign up for API access",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "jitsi": CredentialSpec(
        service_key="jitsi",
        display_name="Jitsi Meet",
        category="apis",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-server-occ",
        doc_hint="Self-hosted video conferencing setup",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
    ),
    "kobo": CredentialSpec(
        service_key="kobo",
        display_name="KoBoToolbox (surveys)",
        category="apis",
        credential_type=CredentialType.API_KEY,
        env_vars=["KOBO_TOKEN"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://support.kobotoolbox.org/api.html",
        doc_hint="Find your token in Account Settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "open_referral": CredentialSpec(
        service_key="open_referral",
        display_name="Open Referral (211)",
        category="apis",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://openreferral.org/documentation/",
        doc_hint="Community resource directory API",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
    ),
    # ── Social ───────────────────────────────────────────────────────────
    "mastodon": CredentialSpec(
        service_key="mastodon",
        display_name="Mastodon / GoToSocial",
        category="social",
        credential_type=CredentialType.BEARER,
        env_vars=["MASTODON_INSTANCE_URL", "MASTODON_ACCESS_TOKEN"],
        validate_fn=f"{_MOD}.validate_mastodon",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://docs.joinmastodon.org/client/token/",
        doc_hint="Create an application for an access token",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "bluesky": CredentialSpec(
        service_key="bluesky",
        display_name="Bluesky (AT Protocol)",
        category="social",
        credential_type=CredentialType.SESSION,
        env_vars=["BLUESKY_HANDLE", "BLUESKY_APP_PASSWORD"],
        validate_fn=f"{_MOD}.validate_bluesky",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://bsky.social/about/blog/2-13-2024-atproto-developer-ecosystem",
        doc_hint="Create an App Password in Settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "instagram": CredentialSpec(
        service_key="instagram",
        display_name="Instagram Business",
        category="social",
        credential_type=CredentialType.OAUTH2,
        env_vars=["INSTAGRAM_ACCESS_TOKEN", "INSTAGRAM_BUSINESS_ID"],
        validate_fn=f"{_MOD}.validate_instagram",
        refresh_fn=None,
        reauth_type=ReauthType.OAUTH_REDIRECT,
        doc_url="https://developers.facebook.com/docs/instagram-api/getting-started",
        doc_hint="Create an app and get a long-lived token",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    # ── Self-Hosted ──────────────────────────────────────────────────────
    "jellyfin": CredentialSpec(
        service_key="jellyfin",
        display_name="Jellyfin (media)",
        category="selfhosted",
        credential_type=CredentialType.API_KEY,
        env_vars=["JELLYFIN_URL", "JELLYFIN_TOKEN"],
        validate_fn=f"{_MOD}.validate_jellyfin",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://jellyfin.org/docs/general/administration/configuration/",
        doc_hint="Find your API key under Dashboard > API Keys",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "gitea": CredentialSpec(
        service_key="gitea",
        display_name="Forgejo / Gitea (code)",
        category="selfhosted",
        credential_type=CredentialType.API_KEY,
        env_vars=["GITEA_URL", "GITEA_TOKEN"],
        validate_fn=f"{_MOD}.validate_gitea",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://forgejo.org/docs/latest/user/api-usage/",
        doc_hint="Generate a token under Settings > Applications",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "homeassistant": CredentialSpec(
        service_key="homeassistant",
        display_name="Home Assistant",
        category="selfhosted",
        credential_type=CredentialType.BEARER,
        env_vars=["HOMEASSISTANT_URL", "HOMEASSISTANT_TOKEN"],
        validate_fn=f"{_MOD}.validate_homeassistant",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token",
        doc_hint="Create a Long-Lived Access Token in your Profile",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "joplin": CredentialSpec(
        service_key="joplin",
        display_name="Joplin (notes)",
        category="selfhosted",
        credential_type=CredentialType.API_KEY,
        env_vars=["JOPLIN_TOKEN"],
        validate_fn=f"{_MOD}.validate_joplin",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://joplinapp.org/help/apps/clipper/",
        doc_hint="Find your token in Tools > Options > Web Clipper",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "pihole": CredentialSpec(
        service_key="pihole",
        display_name="Pi-hole / AdGuard",
        category="selfhosted",
        credential_type=CredentialType.API_KEY,
        env_vars=["PIHOLE_URL", "PIHOLE_TOKEN"],
        validate_fn=f"{_MOD}.validate_pihole",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://docs.pi-hole.net/guides/misc/whitelist-blacklist/",
        doc_hint="Find your API token under Settings > API",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "nextcloud": CredentialSpec(
        service_key="nextcloud",
        display_name="Nextcloud",
        category="selfhosted",
        credential_type=CredentialType.BASIC,
        env_vars=["NEXTCLOUD_URL", "NEXTCLOUD_USER", "NEXTCLOUD_TOKEN"],
        validate_fn=f"{_MOD}.validate_nextcloud",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://docs.nextcloud.com/server/latest/user_manual/en/files/access_webdav.html",
        doc_hint="Use an App Password from Security settings",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "mealie": CredentialSpec(
        service_key="mealie",
        display_name="Mealie (recipes)",
        category="selfhosted",
        credential_type=CredentialType.BEARER,
        env_vars=["MEALIE_URL", "MEALIE_TOKEN"],
        validate_fn=f"{_MOD}.validate_mealie",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://docs.mealie.io/documentation/getting-started/api-usage/",
        doc_hint="Create an API token in User Settings",
        sensitivity=Sensitivity.LOW,
        check_interval=86400,
    ),
    "vaultwarden": CredentialSpec(
        service_key="vaultwarden",
        display_name="Vaultwarden (passwords)",
        category="selfhosted",
        credential_type=CredentialType.API_KEY,
        env_vars=["VAULTWARDEN_URL", "VAULTWARDEN_TOKEN"],
        validate_fn=f"{_MOD}.validate_vaultwarden",
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page",
        doc_hint="Enable admin page to get API token",
        sensitivity=Sensitivity.HIGH,
        check_interval=3600,
    ),
    "searxng": CredentialSpec(
        service_key="searxng",
        display_name="SearXNG (search)",
        category="selfhosted",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.NONE,
        doc_url="https://docs.searxng.org/admin/installation.html",
        doc_hint="Self-hosted meta search engine",
        sensitivity=Sensitivity.LOW,
        check_interval=0,
    ),
    "email_server": CredentialSpec(
        service_key="email_server",
        display_name="Stalwart Mail",
        category="selfhosted",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="https://stalw.art/docs/get-started",
        doc_hint="Admin UI at localhost:8010",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=0,
    ),
    "proton": CredentialSpec(
        service_key="proton",
        display_name="Proton (Bridge & VPN)",
        category="selfhosted",
        credential_type=CredentialType.NONE,
        env_vars=[],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="https://proton.me/mail/bridge",
        doc_hint="Proton Bridge for IMAP/SMTP access",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=0,
    ),
    # ── Channels ─────────────────────────────────────────────────────────
    "telegram": CredentialSpec(
        service_key="telegram",
        display_name="Telegram",
        category="channels",
        credential_type=CredentialType.BEARER,
        env_vars=["TELEGRAM_BOT_TOKEN"],
        validate_fn=f"{_MOD}.validate_telegram",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://core.telegram.org/bots#botfather",
        doc_hint="Get a bot token from @BotFather",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    "discord": CredentialSpec(
        service_key="discord",
        display_name="Discord",
        category="channels",
        credential_type=CredentialType.BEARER,
        env_vars=["DISCORD_BOT_TOKEN"],
        validate_fn=f"{_MOD}.validate_discord",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://discord.com/developers/applications",
        doc_hint="Create a bot and copy the token",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
        required_packages=["discord.py"],
    ),
    "matrix": CredentialSpec(
        service_key="matrix",
        display_name="Matrix",
        category="channels",
        credential_type=CredentialType.SESSION,
        env_vars=["MATRIX_HOMESERVER", "MATRIX_USER", "MATRIX_PASSWORD"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://matrix.org/clients/",
        doc_hint="Matrix homeserver URL, username, and password",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
        required_packages=["matrix-nio"],
    ),
    "signal": CredentialSpec(
        service_key="signal",
        display_name="Signal",
        category="channels",
        credential_type=CredentialType.NONE,
        env_vars=["SIGNAL_CLI_PATH", "SIGNAL_PHONE"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="https://github.com/AsamK/signal-cli",
        doc_hint="Signal CLI with registered phone number",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=0,
    ),
    "whatsapp": CredentialSpec(
        service_key="whatsapp",
        display_name="WhatsApp",
        category="channels",
        credential_type=CredentialType.NONE,
        env_vars=["WHATSAPP_BRIDGE_HOST", "WHATSAPP_BRIDGE_PORT"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="https://github.com/nicehash/whatsapp-bridge",
        doc_hint="WhatsApp bridge connection",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=0,
    ),
    "teams": CredentialSpec(
        service_key="teams",
        display_name="Microsoft Teams",
        category="channels",
        credential_type=CredentialType.TOKEN_PAIR,
        env_vars=["TEAMS_APP_ID", "TEAMS_APP_PASSWORD"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.MULTI_FIELD,
        doc_url="https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams",
        doc_hint="Register a bot in Azure Portal",
        sensitivity=Sensitivity.MEDIUM,
        check_interval=14400,
    ),
    # ── Security ─────────────────────────────────────────────────────────
    "encryption": CredentialSpec(
        service_key="encryption",
        display_name="Encryption Key",
        category="security",
        credential_type=CredentialType.API_KEY,
        env_vars=["FAMILIAR_ENCRYPTION_KEY"],
        validate_fn=f"{_MOD}.validate_encryption",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="https://cryptography.io/en/latest/fernet/",
        doc_hint="Fernet encryption key for at-rest data protection",
        sensitivity=Sensitivity.CRITICAL,
        check_interval=0,
        required_packages=["cryptography"],
    ),
    "dashboard_key": CredentialSpec(
        service_key="dashboard_key",
        display_name="Dashboard Auth Key",
        category="security",
        credential_type=CredentialType.API_KEY,
        env_vars=["FAMILIAR_DASHBOARD_KEY"],
        validate_fn=f"{_MOD}.validate_dashboard_key",
        refresh_fn=None,
        reauth_type=ReauthType.SIMPLE_TOKEN,
        doc_url="",
        doc_hint="Secret key for dashboard API authentication",
        sensitivity=Sensitivity.CRITICAL,
        check_interval=0,
    ),
    "owner_pin": CredentialSpec(
        service_key="owner_pin",
        display_name="Owner PIN",
        category="security",
        credential_type=CredentialType.API_KEY,
        env_vars=["OWNER_PIN_HASH"],
        validate_fn=None,
        refresh_fn=None,
        reauth_type=ReauthType.WIZARD_ONLY,
        doc_url="",
        doc_hint="PBKDF2-hashed owner authentication PIN",
        sensitivity=Sensitivity.CRITICAL,
        check_interval=0,
    ),
}


# =============================================================================
# HELPER FUNCTIONS
# =============================================================================


def get_credential_spec(service_key: str) -> Optional[CredentialSpec]:
    """Look up a credential spec by service key."""
    return CREDENTIAL_REGISTRY.get(service_key)


def list_credentials(
    category: Optional[str] = None,
    configured_only: bool = False,
) -> list[CredentialSpec]:
    """List credential specs, optionally filtered by category or configuration status."""
    specs = list(CREDENTIAL_REGISTRY.values())
    if category:
        specs = [s for s in specs if s.category == category]
    if configured_only:
        specs = [s for s in specs if is_configured(s.service_key)]
    return specs


def is_configured(service_key: str) -> bool:
    """Check if a service's credentials are present (env vars set or token files exist)."""
    spec = get_credential_spec(service_key)
    if spec is None:
        return False
    if spec.credential_type == CredentialType.NONE:
        return True

    # Special case: Google workspace checks token files, not env vars
    if service_key == "google_workspace":
        data_dir = Path.home() / ".familiar" / "data"
        return any(
            (data_dir / f).exists()
            for f in [
                "google_token.json",
                "google_token_drive.json",
                "google_token_contacts.json",
                "google_token_gmail.json",
            ]
        )

    # Check all required env vars are present
    if not spec.env_vars:
        return True
    return all(os.environ.get(var) for var in spec.env_vars)


def validate(service_key: str) -> tuple:
    """Validate credentials for a service. Returns (success, message)."""
    spec = get_credential_spec(service_key)
    if spec is None:
        return False, f"Unknown service: {service_key}"

    if spec.validate_fn is None:
        if not is_configured(service_key):
            return False, "Not configured"
        return True, "No validation available (credentials present)"

    try:
        module_path, func_name = spec.validate_fn.rsplit(".", 1)
        module = importlib.import_module(module_path)
        func = getattr(module, func_name)
        return func()
    except Exception as e:
        logger.warning("Credential validation error for %s: %s", service_key, e)
        return False, f"Validation error: {e}"
