# 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 Health — EnsureCredentials pattern for lifecycle management.

Core loop inspired by GNOME Online Accounts (EnsureCredentials + AttentionNeeded)
and Android AccountManager (invalidate-then-retry):

    1. Check if configured → if not, return configured=False
    2. Check cache (skip if validated within check_interval)
    3. Try refresh_fn if OAuth → update tokens on success
    4. Run validate_fn → update status
    5. Set attention_needed = True on failure

Usage:
    from familiar.core.credential_health import (
        ensure_credentials, get_credential_status,
        invalidate_credential, clear_attention,
    )

    status = ensure_credentials("github")
    if status.attention_needed:
        print(f"{status.service_key} needs re-authorization: {status.last_error}")
"""

from __future__ import annotations

import json
import logging
import threading
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


def _audit_event(service_key: str, event_type: str, details: str, source: str):
    """Fire-and-forget audit log entry for credential events."""
    try:
        from familiar.core.credential_audit import CredentialEventType, log_credential_event

        # Map string to enum if possible, otherwise pass raw string
        try:
            et = CredentialEventType(event_type)
        except ValueError:
            et = event_type
        log_credential_event(service_key, et, details=details, source=source)
    except Exception:
        pass  # audit is best-effort; never block credential operations


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


@dataclass
class CredentialStatus:
    """Runtime state of a credential."""

    service_key: str
    configured: bool = False
    valid: Optional[bool] = None  # None = untested
    attention_needed: bool = False
    last_validated: Optional[str] = None  # ISO timestamp
    last_error: Optional[str] = None
    expires_at: Optional[str] = None  # ISO timestamp for OAuth tokens
    metadata: dict = field(default_factory=dict)

    def to_dict(self) -> dict:
        return {
            "service_key": self.service_key,
            "configured": self.configured,
            "valid": self.valid,
            "attention_needed": self.attention_needed,
            "last_validated": self.last_validated,
            "last_error": self.last_error,
            "expires_at": self.expires_at,
            "metadata": self.metadata,
        }


# =============================================================================
# STATUS STORE (thread-safe, persistent)
# =============================================================================


_STATUS_FILE = Path.home() / ".familiar" / "data" / "credential_status.json"
_status_cache: dict[str, CredentialStatus] = {}
_status_lock = threading.Lock()
_loaded = False


def _utcnow() -> str:
    return datetime.now(timezone.utc).isoformat()


def _load_statuses():
    """Load persisted statuses from disk into cache."""
    global _loaded
    if _loaded:
        return
    with _status_lock:
        if _loaded:
            return
        if _STATUS_FILE.exists():
            try:
                data = json.loads(_STATUS_FILE.read_text(encoding="utf-8"))
                for key, entry in data.items():
                    _status_cache[key] = CredentialStatus(
                        service_key=entry.get("service_key", key),
                        configured=entry.get("configured", False),
                        valid=entry.get("valid"),
                        attention_needed=entry.get("attention_needed", False),
                        last_validated=entry.get("last_validated"),
                        last_error=entry.get("last_error"),
                        expires_at=entry.get("expires_at"),
                        metadata=entry.get("metadata", {}),
                    )
            except (json.JSONDecodeError, OSError) as e:
                logger.warning("Failed to load credential statuses: %s", e)
        _loaded = True


def _save_statuses():
    """Persist status cache to disk."""
    try:
        _STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
        data = {key: status.to_dict() for key, status in _status_cache.items()}
        _STATUS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
    except OSError as e:
        logger.warning("Failed to save credential statuses: %s", e)


def _get_cached(service_key: str) -> Optional[CredentialStatus]:
    """Get cached status if it exists."""
    _load_statuses()
    return _status_cache.get(service_key)


def _set_cached(status: CredentialStatus):
    """Update cache and persist."""
    _load_statuses()
    with _status_lock:
        _status_cache[status.service_key] = status
        _save_statuses()


# =============================================================================
# SILENT REFRESH FUNCTIONS
# =============================================================================


def _refresh_google_oauth() -> tuple:
    """Attempt silent refresh of Google OAuth tokens."""
    data_dir = Path.home() / ".familiar" / "data"
    token_files = [
        data_dir / "google_token.json",
        data_dir / "google_token_gmail.json",
        data_dir / "google_token_drive.json",
        data_dir / "google_token_contacts.json",
    ]
    refreshed = []
    errors = []
    try:
        from google.auth.transport.requests import Request
        from google.oauth2.credentials import Credentials
    except ImportError:
        return False, "google-auth package not installed"

    for tf in token_files:
        if not tf.exists():
            continue
        try:
            creds = Credentials.from_authorized_user_file(str(tf))
            if creds.expired and creds.refresh_token:
                creds.refresh(Request())
                tf.write_text(creds.to_json())
                refreshed.append(tf.stem)
            elif creds.valid:
                refreshed.append(tf.stem)
            else:
                errors.append(f"{tf.stem}: no refresh token")
        except Exception as e:
            errors.append(f"{tf.stem}: {e}")

    if refreshed and not errors:
        return True, f"Refreshed: {', '.join(refreshed)}"
    if refreshed and errors:
        return True, f"Refreshed: {', '.join(refreshed)}; Errors: {'; '.join(errors)}"
    if errors:
        return False, "; ".join(errors)
    return False, "No Google token files found"


# =============================================================================
# CORE API
# =============================================================================


def ensure_credentials(service_key: str) -> CredentialStatus:
    """Validate credentials, attempting silent refresh if needed.

    This is the main entry point — the GNOME Online Accounts
    EnsureCredentials pattern:

    1. Check if configured
    2. Check cache (respect check_interval)
    3. Try refresh_fn (OAuth silent refresh)
    4. Run validate_fn
    5. Set attention_needed on failure
    """
    from familiar.core.credential_registry import (
        get_credential_spec,
        is_configured,
        validate,
    )

    spec = get_credential_spec(service_key)
    if spec is None:
        return CredentialStatus(service_key=service_key, configured=False)

    # Step 1: Check if configured
    configured = is_configured(service_key)
    if not configured:
        status = CredentialStatus(
            service_key=service_key,
            configured=False,
            valid=False,
            last_error="Not configured",
        )
        _set_cached(status)
        return status

    # Step 2: Check cache — skip re-validation if within check_interval
    cached = _get_cached(service_key)
    if cached and cached.valid and cached.last_validated and spec.check_interval > 0:
        try:
            last = datetime.fromisoformat(cached.last_validated)
            elapsed = (datetime.now(timezone.utc) - last).total_seconds()
            if elapsed < spec.check_interval:
                return cached
        except (ValueError, TypeError):
            pass  # re-validate if timestamp is bad

    # Step 3: Try silent refresh for OAuth services
    if spec.refresh_fn is not None:
        try:
            if spec.refresh_fn == "familiar.core.credential_health._refresh_google_oauth":
                ok, msg = _refresh_google_oauth()
            else:
                import importlib

                mod_path, fn_name = spec.refresh_fn.rsplit(".", 1)
                mod = importlib.import_module(mod_path)
                fn = getattr(mod, fn_name)
                ok, msg = fn()

            if ok:
                _audit_event(service_key, "refreshed", msg, "ensure_credentials")
                status = CredentialStatus(
                    service_key=service_key,
                    configured=True,
                    valid=True,
                    attention_needed=False,
                    last_validated=_utcnow(),
                    last_error=None,
                    metadata={"refresh_result": msg},
                )
                _set_cached(status)
                return status
        except Exception as e:
            logger.debug("Silent refresh failed for %s: %s", service_key, e)

    # Step 4: Run validate_fn
    ok, msg = validate(service_key)

    # Step 5: Build status and set attention_needed on failure
    if ok:
        _audit_event(service_key, "validated", msg, "ensure_credentials")
    else:
        _audit_event(service_key, "failed", msg, "ensure_credentials")

    status = CredentialStatus(
        service_key=service_key,
        configured=True,
        valid=ok,
        attention_needed=not ok,
        last_validated=_utcnow(),
        last_error=msg if not ok else None,
        metadata={"validate_result": msg} if ok else {},
    )
    _set_cached(status)
    return status


def get_credential_status(service_key: str) -> CredentialStatus:
    """Get the current status of a credential without re-validating."""
    _load_statuses()
    cached = _status_cache.get(service_key)
    if cached is not None:
        return cached
    # Return a minimal status for unknown/untested credentials
    from familiar.core.credential_registry import is_configured

    return CredentialStatus(
        service_key=service_key,
        configured=is_configured(service_key),
    )


def get_all_statuses(category: Optional[str] = None) -> list[CredentialStatus]:
    """Get statuses for all registered credentials."""
    from familiar.core.credential_registry import CREDENTIAL_REGISTRY, is_configured

    _load_statuses()
    results = []
    for key, spec in CREDENTIAL_REGISTRY.items():
        if category and spec.category != category:
            continue
        cached = _status_cache.get(key)
        if cached is not None:
            results.append(cached)
        else:
            results.append(
                CredentialStatus(
                    service_key=key,
                    configured=is_configured(key),
                )
            )
    return results


def invalidate_credential(service_key: str):
    """Clear cached status, forcing re-validation on next ensure_credentials() call.

    This is the Android AccountManager invalidateAuthToken() pattern.
    """
    with _status_lock:
        if service_key in _status_cache:
            _status_cache[service_key].valid = None
            _status_cache[service_key].last_validated = None
            _save_statuses()
            _audit_event(service_key, "invalidated", "", "invalidate_credential")
            logger.debug("Invalidated credential cache for %s", service_key)


def clear_attention(service_key: str):
    """Clear the attention_needed flag after user has re-authorized."""
    with _status_lock:
        if service_key in _status_cache:
            _status_cache[service_key].attention_needed = False
            _save_statuses()
            logger.debug("Cleared attention flag for %s", service_key)


def get_attention_needed() -> list[CredentialStatus]:
    """Get all credentials that need user attention.

    Filters out stale cache entries where the service is no longer configured
    (env vars removed, credentials deleted, etc.).
    """
    from familiar.core.credential_registry import is_configured

    _load_statuses()
    results = []
    for s in _status_cache.values():
        if s.attention_needed:
            if is_configured(s.service_key):
                results.append(s)
            else:
                # Clear stale attention for unconfigured services
                s.attention_needed = False
    if results != [s for s in _status_cache.values() if s.attention_needed]:
        _save_statuses()
    return results


def validate_all(category: Optional[str] = None) -> dict:
    """Validate all configured credentials. Returns summary dict.

    Used by startup validation (Sprint 2) and manual "test all" button.
    """
    from familiar.core.credential_registry import CREDENTIAL_REGISTRY, is_configured

    results = {"total": 0, "configured": 0, "valid": 0, "attention": 0, "errors": []}

    for key, spec in CREDENTIAL_REGISTRY.items():
        if category and spec.category != category:
            continue
        results["total"] += 1
        if not is_configured(key):
            continue
        results["configured"] += 1

        status = ensure_credentials(key)
        if status.valid:
            results["valid"] += 1
        elif status.attention_needed:
            results["attention"] += 1
            results["errors"].append({"service_key": key, "error": status.last_error})

    return results


# =============================================================================
# TOOL ↔ CREDENTIAL MAPPING (Sprint 5)
# =============================================================================

# Maps tool name prefixes and exact names to required credential service_keys.
# Used by the tool executor pre-flight check.
TOOL_CREDENTIAL_MAP: dict[str, list[str]] = {
    # GitHub skill tools
    "github_": ["github"],
    # Google Workspace tools
    "gcal_": ["google_workspace"],
    "gmail_": ["google_workspace"],
    "gdrive_": ["google_workspace"],
    "gcontacts_": ["google_workspace"],
    # Email tools
    "send_email": ["email"],
    "search_email": ["email"],
    "read_email": ["email"],
    # SMS tools
    "send_sms": ["sms"],
    # Slack tools
    "slack_": ["slack"],
    # HubSpot tools
    "hubspot_": ["hubspot"],
    # Todoist tools
    "todoist_": ["todoist"],
    # Stripe tools
    "stripe_": ["stripe"],
    # Square tools
    "square_": ["square"],
    # QuickBooks tools
    "qb_": ["quickbooks"],
    # Reddit tools
    "reddit_": ["reddit"],
    # Brave search tools
    "brave_search": ["brave_search"],
    # Spoonacular tools
    "recipe_search": ["spoonacular"],
    # Etsy tools
    "etsy_": ["etsy"],
    # Clockify tools
    "clockify_": ["clockify"],
    # Mastodon tools
    "mastodon_": ["mastodon"],
    "toot_": ["mastodon"],
    # Bluesky tools
    "bluesky_": ["bluesky"],
    "bsky_": ["bluesky"],
    # Telegram tools
    "telegram_": ["telegram"],
    # Discord tools
    "discord_": ["discord"],
    # Vaultwarden tools
    "vw_": ["vaultwarden"],
    # Self-hosted tools
    "jellyfin_": ["jellyfin"],
    "gitea_": ["gitea"],
    "ha_": ["homeassistant"],
    "joplin_": ["joplin"],
    "pihole_": ["pihole"],
    "nextcloud_": ["nextcloud"],
    "mealie_": ["mealie"],
    # Calendar (CalDAV)
    "calendar_": ["google_workspace"],
    # MailerLite tools
    "mailerlite_": ["mailerlite"],
    # Shippo tools
    "shippo_": ["shippo"],
    # Cal.com tools
    "calcom_": ["calcom"],
    # Instagram tools
    "instagram_": ["instagram"],
}


def get_required_credentials(tool_name: str) -> list[str]:
    """Return the list of credential service_keys required by a tool.

    Matches by exact name first, then by prefix.
    Returns an empty list if the tool has no credential requirements.
    """
    # Exact match first
    if tool_name in TOOL_CREDENTIAL_MAP:
        return TOOL_CREDENTIAL_MAP[tool_name]

    # Prefix match
    for prefix, creds in TOOL_CREDENTIAL_MAP.items():
        if prefix.endswith("_") and tool_name.startswith(prefix):
            return creds

    return []


def credential_preflight(tool_name: str) -> tuple[bool, str]:
    """Check if all required credentials for a tool are available.

    Returns (ok, message).  If ok is False, the tool should not be executed
    and the message should be returned to the LLM as the tool result.
    """
    required = get_required_credentials(tool_name)
    if not required:
        return True, ""

    for service_key in required:
        status = ensure_credentials(service_key)
        if not status.configured:
            from familiar.core.credential_registry import get_credential_spec

            spec = get_credential_spec(service_key)
            name = spec.display_name if spec else service_key
            return False, (
                f"Error: {name} is not configured. "
                f"The user needs to set it up in Settings > Credentials "
                f"in the dashboard before this tool can be used."
            )
        if status.attention_needed or status.valid is False:
            from familiar.core.credential_registry import get_credential_spec

            spec = get_credential_spec(service_key)
            name = spec.display_name if spec else service_key
            return False, (
                f"Error: {name} needs re-authorization. "
                f"{status.last_error or 'Credential is invalid.'}  "
                f"The user can fix this in Settings > Credentials in the dashboard."
            )

    return True, ""


def credential_retry_on_auth_failure(tool_name: str, error_text: str) -> tuple[bool, str]:
    """Attempt credential recovery after an auth failure.

    Implements the Android AccountManager invalidate-then-retry pattern:
    1. Check if the error looks like an auth failure
    2. Invalidate the cached credential
    3. Re-run ensure_credentials (which attempts silent refresh)
    4. If refresh succeeded, return (True, "") — caller should retry
    5. If refresh failed, return (False, message) — don't retry

    Returns (should_retry, message).
    """
    import re

    auth_patterns = re.compile(
        r"401|403|unauthorized|authentication.*fail|token.*expir|"
        r"invalid.*credentials|bad credentials|access.*denied",
        re.I,
    )
    if not auth_patterns.search(error_text):
        return False, ""

    required = get_required_credentials(tool_name)
    if not required:
        return False, ""

    for service_key in required:
        invalidate_credential(service_key)
        status = ensure_credentials(service_key)
        if status.valid:
            logger.info(
                "Credential for %s refreshed after auth failure — retrying tool %s",
                service_key,
                tool_name,
            )
            return True, ""
        else:
            from familiar.core.credential_registry import get_credential_spec

            spec = get_credential_spec(service_key)
            name = spec.display_name if spec else service_key
            return False, (
                f"Error: {name} needs re-authorization. "
                f"{status.last_error or 'Could not refresh credential.'}  "
                f"Visit Settings > Credentials in the dashboard to fix this."
            )

    return False, ""


# =============================================================================
# TEST HELPERS (for resetting state in tests)
# =============================================================================


def _reset_for_testing():
    """Reset module state for test isolation."""
    global _loaded, _status_cache
    with _status_lock:
        _status_cache = {}
        _loaded = False
