"""
Outreach.io sales engagement client.

Provides integration with Outreach's REST API for managing prospects and sequences.
Uses OAuth2 for authentication.

API Documentation: https://developers.outreach.io/api/
"""

from __future__ import annotations

import http.server
from datetime import datetime, timezone
import json
import logging
import os
import socket
import socketserver
import time
import urllib.parse
import webbrowser
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional

import click

from g_gremlin import dev_mode
from g_gremlin.base import (
    AuthenticationError,
    BaseIntegration,
    IntegrationError,
    RateLimitError,
    RetryConfig,
    TransientError,
)
from g_gremlin.contracts import BatchOutcome, CapabilityCheck
from g_gremlin.credentials import resolve_credentials
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError
from g_gremlin.pacing import BatchPacer, PacingProfile, get_outreach_profile

logger = logging.getLogger(__name__)

API_BASE = "https://api.outreach.io/api/v2"
AUTH_URL = "https://api.outreach.io/oauth/authorize"
TOKEN_URL = "https://api.outreach.io/oauth/token"
CONFIG_PATH = Path.home() / ".g_gremlin" / "outreach.json"
FOUNDRYOPS_DEFAULT_API_BASE = "https://api.foundryops.io"

DEFAULT_RETRY_CONFIG = RetryConfig(max_attempts=4, base_delay=1.0, max_delay=30.0)


class OutreachError(RuntimeError):
    """Raised when Outreach operations fail."""


class OutreachAuthError(OutreachError):
    """Raised when authentication fails."""


class OutreachScopeError(OutreachAuthError):
    """Raised when access is denied due to missing OAuth scopes."""


@dataclass
class OutreachSequence:
    """Outreach sequence representation."""

    id: str
    name: str
    enabled: bool = True
    sequence_type: str = ""
    step_count: int = 0
    prospect_count: int = 0

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)


@dataclass
class OutreachProspect:
    """Outreach prospect representation."""

    id: Optional[str] = None
    email: Optional[str] = None
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    title: Optional[str] = None
    company_name: Optional[str] = None
    linkedin_url: Optional[str] = None
    phone: Optional[str] = None
    custom: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)

    def to_api_format(self) -> Dict[str, Any]:
        """Convert to Outreach API request format."""
        attributes: Dict[str, Any] = {}
        if self.email:
            attributes["emails"] = [self.email]
        if self.first_name:
            attributes["firstName"] = self.first_name
        if self.last_name:
            attributes["lastName"] = self.last_name
        if self.title:
            attributes["title"] = self.title
        if self.company_name:
            attributes["company"] = self.company_name
        if self.linkedin_url:
            attributes["linkedInUrl"] = self.linkedin_url
        if self.phone:
            attributes["homePhones"] = [self.phone]
        if self.custom:
            attributes["custom"] = self.custom

        return {
            "data": {
                "type": "prospect",
                "attributes": attributes,
            }
        }


@dataclass
class SequenceEnrollment:
    """Result of enrolling a prospect in a sequence."""

    prospect_id: str
    sequence_id: str
    sequence_state_id: Optional[str] = None
    status: str = "pending"
    error: Optional[str] = None

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)


@dataclass
class MissingScopesResult:
    required: List[str]
    active: List[str]
    missing: List[str]

    @property
    def covered(self) -> bool:
        return not self.missing


def _load_config() -> Dict[str, Any]:
    if not CONFIG_PATH.exists():
        return {}
    try:
        return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
    except Exception:
        return {}


def _save_config(config: Dict[str, Any]) -> None:
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(config, indent=2), encoding="utf-8")


def _parse_expiry(value: Any) -> Optional[float]:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        raw = value.strip()
        if not raw:
            return None
        try:
            return float(raw)
        except ValueError:
            pass
        normalized = raw.replace("Z", "+00:00")
        try:
            parsed = datetime.fromisoformat(normalized)
        except ValueError:
            return None
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=timezone.utc)
        return parsed.timestamp()
    return None


def _resolve_foundryops_api_base(explicit: Optional[str] = None) -> str:
    return (
        explicit
        or os.getenv("FOUNDRYOPS_API_URL")
        or os.getenv("FOUNDRYMATCH_API_URL")
        or FOUNDRYOPS_DEFAULT_API_BASE
    ).rstrip("/")


def _resolve_foundryops_auth_headers() -> Dict[str, str]:
    auth_token = os.getenv("FOUNDRYMATCH_AUTH_TOKEN")
    if auth_token:
        return {"Authorization": f"Bearer {auth_token}"}

    api_key = os.getenv("FOUNDRYMATCH_API_KEY")
    if api_key:
        return {"X-API-Key": api_key}

    try:
        from g_gremlin.credentials import license as license_mod

        license_token = license_mod.get_license_token()
    except Exception:
        license_token = None
    if license_token:
        return {"Authorization": f"Bearer {license_token}"}

    if dev_mode.is_dev_mode_enabled():
        return {"Authorization": "Bearer dev-token"}

    return {}


def get_active_scopes() -> List[str]:
    config = _load_config()
    requested = config.get("requested_scopes")
    if isinstance(requested, list) and requested:
        return [str(scope) for scope in requested]
    profile = str(config.get("scope_profile") or "default")
    return SCOPE_PROFILES.get(profile, OUTREACH_SCOPES)


def check_scope_coverage(required: List[str]) -> MissingScopesResult:
    active = get_active_scopes()
    missing = [scope for scope in required if scope not in active]
    return MissingScopesResult(required=required, active=active, missing=missing)


# ------------------------------------------------------------------
# OAuth Flow Helper
# ------------------------------------------------------------------

# Outreach OAuth scopes - required parameter
# Format: resource.permission (e.g., prospects.all, sequences.read)
# See: https://developers.outreach.io/api/oauth/
SCOPE_PROFILES: Dict[str, List[str]] = {
    "default": [
        "prospects.read",
        "prospects.write",
        "sequences.read",
        "sequenceStates.read",
        "sequenceStates.write",
        "users.read",
        "mailboxes.read",
    ],
    "read_only": [
        "prospects.read",
        "sequences.read",
        "sequenceStates.read",
        "users.read",
        "mailboxes.read",
        "accounts.read",
        "tasks.read",
    ],
    "admin": [
        "prospects.all",
        "sequences.all",
        "sequenceStates.all",
        "users.read",
        "mailboxes.all",
        "accounts.all",
        "tasks.all",
        "webhooks.all",
        "roles.read",
        "teams.read",
    ],
}

OUTREACH_SCOPES: List[str] = SCOPE_PROFILES["default"]

DEFAULT_REDIRECT_PORT = 8484
REDIRECT_PATH = "/callback"
DEFAULT_BRIDGE_REDIRECT_URI = "https://api.foundryops.io/api/v1/outreach/callback"


def _get_default_redirect_uri() -> str:
    env_value = os.getenv("G_GREMLIN_OUTREACH_REDIRECT_URI")
    if env_value and env_value.strip():
        return env_value.strip()
    return DEFAULT_BRIDGE_REDIRECT_URI


def _normalize_redirect_uri(redirect_uri: str) -> str:
    parsed = urllib.parse.urlparse(redirect_uri)
    if not parsed.path or parsed.path == "/":
        return redirect_uri.rstrip("/") + REDIRECT_PATH
    return redirect_uri


class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
    """HTTP handler that captures the OAuth callback code."""

    authorization_code: Optional[str] = None
    error: Optional[str] = None

    def do_GET(self) -> None:
        """Handle the OAuth callback GET request."""
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)

        if "code" in params:
            _OAuthCallbackHandler.authorization_code = params["code"][0]
            self._send_success_response()
        elif "error" in params:
            _OAuthCallbackHandler.error = params.get("error_description", params["error"])[0]
            self._send_error_response(_OAuthCallbackHandler.error)
        else:
            self._send_error_response("No authorization code received")

    def _send_success_response(self) -> None:
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        html = """
        <!DOCTYPE html>
        <html>
        <head><title>Outreach Authorization</title></head>
        <body style="font-family: sans-serif; text-align: center; padding: 50px;">
            <h1 style="color: #22c55e;">&#10004; Authorization Successful!</h1>
            <p>You can close this window and return to the terminal.</p>
            <script>setTimeout(() => window.close(), 3000);</script>
        </body>
        </html>
        """
        self.wfile.write(html.encode())

    def _send_error_response(self, error: str) -> None:
        self.send_response(400)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        html = f"""
        <!DOCTYPE html>
        <html>
        <head><title>Outreach Authorization</title></head>
        <body style="font-family: sans-serif; text-align: center; padding: 50px;">
            <h1 style="color: #ef4444;">&#10008; Authorization Failed</h1>
            <p>{error}</p>
            <p>Please close this window and try again.</p>
        </body>
        </html>
        """
        self.wfile.write(html.encode())

    def log_message(self, format: str, *args) -> None:
        # Suppress default logging
        pass


def _find_available_port(preferred: int = DEFAULT_REDIRECT_PORT) -> int:
    """Find an available port, starting with the preferred port."""
    for port in [preferred, preferred + 1, preferred + 2, 0]:
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(("localhost", port))
                return s.getsockname()[1]
        except OSError:
            continue
    raise OutreachError("Could not find an available port for OAuth callback")


def run_oauth_flow(
    client_id: str,
    client_secret: str,
    redirect_port: Optional[int] = None,
    redirect_uri: Optional[str] = None,
    scopes: Optional[List[str]] = None,
    open_browser: bool = True,
) -> Dict[str, Any]:
    """
    Run the Outreach OAuth2 authorization flow.

    This will:
    1. Start a local HTTP server to receive the callback
    2. Open the browser to Outreach's authorization URL
    3. Wait for the user to authorize
    4. Exchange the authorization code for tokens
    5. Return the token response

    Args:
        client_id: Outreach OAuth client ID
        client_secret: Outreach OAuth client secret
        redirect_port: Port for callback server (default: 8484)
        redirect_uri: Custom redirect URI (for tunnels like cloudflared/ngrok).
                      Defaults to the FoundryOps HTTPS bridge or
                      G_GREMLIN_OUTREACH_REDIRECT_URI if set.
                      If provided, overrides redirect_port for the OAuth URL,
                      but still listens on the port locally.
        scopes: OAuth scopes to request (default: common prospect/sequence scopes)
        open_browser: Whether to automatically open the browser

    Returns:
        Dict containing access_token, refresh_token, expires_in, etc.

    Raises:
        OutreachError: If the OAuth flow fails
    """
    import requests  # Import here to avoid circular dependency

    desired_redirect_uri = redirect_uri or _get_default_redirect_uri()
    normalized_redirect_uri = (
        _normalize_redirect_uri(desired_redirect_uri) if desired_redirect_uri else None
    )
    using_bridge = normalized_redirect_uri == DEFAULT_BRIDGE_REDIRECT_URI

    if using_bridge:
        if redirect_port not in (None, DEFAULT_REDIRECT_PORT):
            logger.warning(
                "Outreach OAuth bridge uses port %s; ignoring custom --port %s",
                DEFAULT_REDIRECT_PORT,
                redirect_port,
            )
        port = DEFAULT_REDIRECT_PORT
    else:
        port = redirect_port if redirect_port is not None else _find_available_port()

    # Use custom redirect_uri if provided, otherwise default to bridge or localhost.
    if normalized_redirect_uri:
        redirect_uri = normalized_redirect_uri
    else:
        redirect_uri = f"http://localhost:{port}{REDIRECT_PATH}"
    # Use provided scopes, or defaults (which may be empty for "all scopes" mode)
    effective_scopes = scopes if scopes is not None else OUTREACH_SCOPES
    logger.info("Outreach OAuth scopes requested: %s", " ".join(effective_scopes))

    # Reset handler state
    _OAuthCallbackHandler.authorization_code = None
    _OAuthCallbackHandler.error = None

    # Build authorization URL - scope is required by Outreach
    auth_params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": " ".join(effective_scopes),
    }
    auth_url = f"{AUTH_URL}?{urllib.parse.urlencode(auth_params)}"

    # Start callback server
    server = socketserver.TCPServer(("localhost", port), _OAuthCallbackHandler)
    server.timeout = 300  # 5 minute timeout

    logger.info(f"Starting OAuth callback server on port {port}")

    # Open browser
    if open_browser:
        click.echo("\nOpening browser for Outreach authorization...")
        click.echo(f"If the browser doesn't open, visit this URL:\n{auth_url}\n")
        webbrowser.open(auth_url)
    else:
        click.echo(f"\nPlease visit this URL to authorize:\n{auth_url}\n")

    # Wait for callback
    click.echo("Waiting for authorization (timeout: 5 minutes)...")
    try:
        server.handle_request()
    finally:
        server.server_close()

    # Check result
    if _OAuthCallbackHandler.error:
        raise OutreachError(f"Authorization failed: {_OAuthCallbackHandler.error}")

    if not _OAuthCallbackHandler.authorization_code:
        raise OutreachError("No authorization code received. Did you complete the authorization?")

    # Exchange code for tokens
    click.echo("Exchanging authorization code for tokens...")
    token_data = {
        "grant_type": "authorization_code",
        "client_id": client_id,
        "client_secret": client_secret,
        "code": _OAuthCallbackHandler.authorization_code,
        "redirect_uri": redirect_uri,
    }

    try:
        resp = requests.post(TOKEN_URL, json=token_data, timeout=30)
        resp.raise_for_status()
        tokens = resp.json()
    except requests.RequestException as exc:
        raise OutreachError(f"Token exchange failed: {exc}") from exc

    if "error" in tokens:
        raise OutreachError(f"Token exchange failed: {tokens.get('error_description', tokens['error'])}")

    return tokens


def authenticate(
    client_id: Optional[str] = None,
    client_secret: Optional[str] = None,
    save_to_config: bool = True,
    redirect_port: Optional[int] = None,
    redirect_uri: Optional[str] = None,
    open_browser: bool = True,
    scope_profile: Optional[str] = None,
    scopes: Optional[List[str]] = None,
) -> Dict[str, Any]:
    """
    Interactive OAuth authentication for Outreach.

    If client_id/client_secret not provided, prompts for them interactively.
    After successful auth, saves credentials to ~/.g_gremlin/outreach.json.

    Args:
        client_id: OAuth client ID (prompts if not provided)
        client_secret: OAuth client secret (prompts if not provided)
        save_to_config: Whether to save credentials to config file
        redirect_port: Port for callback server (default: 8484)
        redirect_uri: Custom redirect URI for tunnels (cloudflared/ngrok).
                      Defaults to the FoundryOps HTTPS bridge.
        open_browser: Whether to automatically open the browser

    Returns:
        Dict containing access_token, refresh_token, and other token info
    """
    # Get credentials interactively if not provided
    if not client_id:
        click.echo("\n=== Outreach OAuth Setup ===")
        click.echo("You'll need your Outreach app's OAuth credentials.")
        click.echo("Find these at: https://accounts.outreach.io/apps\n")
        client_id = click.prompt("Client ID", type=str).strip()

    if not client_secret:
        client_secret = click.prompt("Client Secret", type=str, hide_input=True).strip()

    # Resolve scope profile + scopes
    if scopes is not None:
        effective_scopes = scopes
        profile_name = "custom"
    else:
        profile_name = scope_profile or "default"
        effective_scopes = SCOPE_PROFILES.get(profile_name, OUTREACH_SCOPES)

    # Run OAuth flow
    tokens = run_oauth_flow(
        client_id, client_secret,
        redirect_port=redirect_port,
        redirect_uri=redirect_uri,
        scopes=effective_scopes,
        open_browser=open_browser,
    )

    if save_to_config:
        # Save to config file
        config = _load_config()
        config["client_id"] = client_id
        config["client_secret"] = client_secret
        config["refresh_token"] = tokens.get("refresh_token")
        config["access_token"] = tokens.get("access_token")
        config["token_expires_at"] = time.time() + float(tokens.get("expires_in", 7200))
        config["scope_profile"] = profile_name
        config["requested_scopes"] = list(effective_scopes)
        _save_config(config)
        click.echo(f"\nCredentials saved to {CONFIG_PATH}")

    return tokens


def save_manual_credentials(
    client_id: str,
    client_secret: str,
    refresh_token: str,
    scope_profile: Optional[str] = None,
    scopes: Optional[List[str]] = None,
) -> None:
    """
    Save manually-obtained Outreach credentials.

    Use this when you already have OAuth credentials from Outreach
    (e.g., from another tool or from the Outreach developer console).

    Args:
        client_id: OAuth client ID
        client_secret: OAuth client secret
        refresh_token: OAuth refresh token
    """
    config = _load_config()
    config["client_id"] = client_id
    config["client_secret"] = client_secret
    config["refresh_token"] = refresh_token
    # Don't set access_token - will be fetched on first use
    config["access_token"] = None
    config["token_expires_at"] = 0
    if scopes is not None:
        config["scope_profile"] = "custom"
        config["requested_scopes"] = list(scopes)
    elif scope_profile:
        config["scope_profile"] = scope_profile
        config["requested_scopes"] = list(SCOPE_PROFILES.get(scope_profile, OUTREACH_SCOPES))
    _save_config(config)
    click.echo(f"Credentials saved to {CONFIG_PATH}")


class OutreachClient(BaseIntegration):
    """Outreach API client."""

    SERVICE_NAME = "outreach"

    def __init__(
        self,
        client_id: Optional[str] = None,
        client_secret: Optional[str] = None,
        refresh_token: Optional[str] = None,
        access_token: Optional[str] = None,
        credential_provider: CredentialProvider | None = None,
        retry_config: RetryConfig | None = None,
    ) -> None:
        super().__init__(
            credential_provider=credential_provider,
            default_retry_config=retry_config or DEFAULT_RETRY_CONFIG,
        )
        self.config = _load_config()
        creds = self._resolve_creds(client_id, client_secret, refresh_token, access_token, credential_provider)
        self._client_id = creds.get("client_id") or ""
        self._client_secret = creds.get("client_secret") or ""
        self._refresh_token = creds.get("refresh_token")
        self._access_token = creds.get("access_token")
        self._token_expires_at = creds.get("token_expires_at")
        self._auth_mode = str(creds.get("auth_mode") or self.config.get("auth_mode") or "local").strip().lower()

    def _request(
        self,
        method: str,
        url: str,
        *,
        params: dict | None = None,
        json: dict | None = None,
        headers: dict | None = None,
        timeout: float = 30.0,
        retry_config: RetryConfig | None = None,
        auth: tuple | None = None,
        allow_statuses: tuple[int, ...] = (),
    ) -> Any:
        # Allow 403 through so we can inspect response body for scope hints
        allow = tuple(set(allow_statuses + (403,)))
        response = super()._request(
            method,
            url,
            params=params,
            json=json,
            headers=headers,
            timeout=timeout,
            retry_config=retry_config,
            auth=auth,
            allow_statuses=allow,
        )

        if response.status_code == 403 and 403 not in allow_statuses:
            body = (response.text or "").lower()
            scope_hints = ["insufficient_scope", "scope", "forbidden"]
            if any(hint in body for hint in scope_hints):
                raise OutreachScopeError(
                    "Outreach access denied due to missing scopes. "
                    "Run g-gremlin outreach scopes to diagnose, then re-auth with "
                    "g-gremlin outreach auth --profile=admin (or --scopes \"...\")."
                )
            raise AuthenticationError(
                "Outreach access forbidden (403). "
                "Run g-gremlin outreach doctor --json for details."
            )

        return response

    def _resolve_creds(
        self,
        client_id: Optional[str],
        client_secret: Optional[str],
        refresh_token: Optional[str],
        access_token: Optional[str],
        credential_provider: CredentialProvider | None,
    ) -> Dict[str, Any]:
        if client_id and client_secret and refresh_token:
            return {
                "client_id": client_id,
                "client_secret": client_secret,
                "refresh_token": refresh_token,
                "access_token": access_token,
                "auth_mode": "local",
            }
        try:
            creds = resolve_credentials(
                "outreach",
                required_keys=["client_id", "client_secret", "refresh_token"],
                optional_keys=["access_token", "token_expires_at"],
                credential_provider=credential_provider,
            )
            # Merge into config for caching
            self.config.setdefault("access_token", creds.get("access_token"))
            if creds.get("token_expires_at"):
                self.config.setdefault("token_expires_at", creds.get("token_expires_at"))
            creds["auth_mode"] = "local"
            return creds
        except SecretNotFoundError:
            auth_mode = str(self.config.get("auth_mode") or "").strip().lower()
            if auth_mode == "server":
                return {
                    "client_id": "",
                    "client_secret": "",
                    "refresh_token": None,
                    "access_token": self.config.get("access_token"),
                    "token_expires_at": self.config.get("token_expires_at"),
                    "auth_mode": "server",
                }

            # Fallback to legacy config file.
            cid = (self.config.get("client_id") or "").strip()
            csecret = (self.config.get("client_secret") or "").strip()
            rtoken = (self.config.get("refresh_token") or "").strip()
            if cid and csecret and rtoken:
                return {
                    "client_id": cid,
                    "client_secret": csecret,
                    "refresh_token": rtoken,
                    "access_token": self.config.get("access_token"),
                    "token_expires_at": self.config.get("token_expires_at"),
                    "auth_mode": "local",
                }
            raise click.ClickException(
                "Outreach OAuth credentials not configured. "
                "Set env vars G_GREMLIN_OUTREACH_CLIENT_ID, G_GREMLIN_OUTREACH_CLIENT_SECRET, "
                "G_GREMLIN_OUTREACH_REFRESH_TOKEN, run g-gremlin auth set outreach, "
                "or run g-gremlin outreach oauth connect."
            )

    # ------------------------------------------------------------------
    # Token handling
    # ------------------------------------------------------------------
    def _token_valid(self) -> bool:
        expires_at = self.config.get("token_expires_at") or self._token_expires_at
        access_token = self.config.get("access_token") or self._access_token
        if not access_token or not expires_at:
            return False
        expires_ts = _parse_expiry(expires_at)
        if expires_ts is None:
            return False
        return time.time() < expires_ts - 60  # 60s buffer

    def _store_tokens(self, tokens: Dict[str, Any]) -> None:
        access_token = tokens.get("access_token")
        if not access_token:
            return

        expires_ts = _parse_expiry(tokens.get("expires_at"))
        if expires_ts is None:
            try:
                expires_in = float(tokens.get("expires_in", 7200))
            except Exception:
                expires_in = 7200.0
            expires_ts = time.time() + expires_in

        self._access_token = access_token
        self._token_expires_at = expires_ts
        if self._auth_mode != "server":
            self._refresh_token = tokens.get("refresh_token") or self._refresh_token
        else:
            self._refresh_token = None

        self.config["access_token"] = access_token
        self.config["token_expires_at"] = self._token_expires_at
        self.config["auth_mode"] = self._auth_mode

        if tokens.get("scope_profile"):
            self.config["scope_profile"] = tokens.get("scope_profile")
        if tokens.get("org_id") is not None:
            self.config["org_id"] = tokens.get("org_id")
        if tokens.get("org_name") is not None:
            self.config["org_name"] = tokens.get("org_name")

        if self._auth_mode == "server":
            self.config.pop("refresh_token", None)
            self.config.pop("client_secret", None)
            self.config.pop("client_id", None)
        else:
            self.config["refresh_token"] = self._refresh_token
        _save_config(self.config)

    def _server_refresh_token(self) -> str:
        message = "Could not refresh token. Run 'g-gremlin outreach oauth connect' to re-authenticate."
        headers = _resolve_foundryops_auth_headers()
        if not headers:
            raise OutreachAuthError(message)

        try:
            import requests
        except Exception as exc:
            raise OutreachAuthError(message) from exc

        api_base = _resolve_foundryops_api_base(
            str(self.config.get("foundryops_api_base") or self.config.get("api_base") or "")
        )
        params: Dict[str, str] = {}
        workspace_id = str(self.config.get("workspace_id") or "").strip()
        if workspace_id:
            params["workspace_id"] = workspace_id

        try:
            resp = requests.get(
                f"{api_base}/api/v2/integrations/outreach/cli/token",
                params=params or None,
                headers=headers,
                timeout=20,
            )
        except Exception as exc:
            raise OutreachAuthError(message) from exc

        if resp.status_code in (401, 403):
            raise OutreachAuthError(message)
        if resp.status_code != 200:
            raise OutreachAuthError(message)

        try:
            payload = resp.json()
        except Exception as exc:
            raise OutreachAuthError(message) from exc

        access_token = str(payload.get("access_token") or "").strip()
        if not access_token:
            raise OutreachAuthError(message)

        self._store_tokens(
            {
                "access_token": access_token,
                "expires_at": payload.get("expires_at"),
                "scope_profile": payload.get("scope_profile"),
                "org_id": payload.get("org_id"),
                "org_name": payload.get("org_name"),
            }
        )
        return access_token

    def _refresh_access_token(self) -> Dict[str, Any]:
        """Refresh the access token using the refresh token."""
        if self._auth_mode == "server":
            return {"access_token": self._server_refresh_token()}

        if not self._refresh_token:
            raise OutreachAuthError("No refresh token available.")

        data = {
            "grant_type": "refresh_token",
            "client_id": self._client_id,
            "client_secret": self._client_secret,
            "refresh_token": self._refresh_token,
        }

        try:
            resp = self._post(
                TOKEN_URL,
                json=data,
                headers={"Content-Type": "application/json"},
                allow_statuses=(400, 401),
                timeout=20,
            )
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        if resp.status_code in (400, 401):
            raise OutreachAuthError(f"Token refresh failed: {resp.text}")

        try:
            return resp.json()
        except Exception as exc:
            raise OutreachError(f"Invalid token response: {exc}") from exc

    def _get_access_token(self) -> str:
        if self._token_valid():
            return self.config.get("access_token") or self._access_token

        if self._auth_mode == "server":
            return self._server_refresh_token()

        tokens = self._refresh_access_token()
        self._store_tokens(tokens)
        access_token = tokens.get("access_token")
        if not access_token:
            raise OutreachError("Token response missing access_token.")
        return access_token

    def _headers(self) -> Dict[str, str]:
        token = self._get_access_token()
        return {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/vnd.api+json",
            "Accept": "application/vnd.api+json",
        }

    # ------------------------------------------------------------------
    # API methods
    # ------------------------------------------------------------------
    def test_connection(self) -> Dict[str, Any]:
        """Test API connection by fetching API root info."""
        try:
            # Outreach doesn't have /users/me - use root endpoint for org info
            resp = self._get(API_BASE, headers=self._headers())
            data = resp.json()
            meta = data.get("meta", {})
            org = meta.get("org", {})
            return {
                "status": "ok",
                "org_id": org.get("id"),
                "org_name": org.get("name", "unknown"),
                "email": meta.get("user", {}).get("email", "unknown"),
                "name": meta.get("user", {}).get("name", "unknown"),
            }
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

    def whoami(self) -> Dict[str, Any]:
        """Return current user + org information."""
        try:
            resp = self._get(
                f"{API_BASE}/users/me",
                headers=self._headers(),
                allow_statuses=(404, 405),
            )
            if resp.status_code in (404, 405):
                # Some Outreach tenants do not expose /users/me. Fall back to API root meta.
                root_resp = self._get(API_BASE, headers=self._headers())
                root_payload = root_resp.json()
                meta = root_payload.get("meta", {}) or {}
                user = meta.get("user", {}) or {}
                org = meta.get("org", {}) or {}
                return {
                    "user_id": str(user.get("id")) if user.get("id") is not None else None,
                    "email": user.get("email"),
                    "name": user.get("name"),
                    "org_id": str(org.get("id")) if org.get("id") is not None else None,
                    "org_name": org.get("name"),
                    "role": None,
                }
            payload = resp.json()
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        data = payload.get("data", {}) or {}
        attrs = data.get("attributes", {}) or {}
        relationships = data.get("relationships", {}) or {}

        user_id = data.get("id")
        email = attrs.get("email")
        if not email:
            emails = attrs.get("emails") or []
            if emails:
                email = emails[0]

        name = attrs.get("name")
        if not name:
            first = attrs.get("firstName") or attrs.get("first_name")
            last = attrs.get("lastName") or attrs.get("last_name")
            name = " ".join([part for part in [first, last] if part]) or None

        role = attrs.get("role") or attrs.get("roleName")

        org_id = None
        org_name = None
        org_rel = relationships.get("organization") or relationships.get("org") or {}
        if isinstance(org_rel, dict):
            rel_data = org_rel.get("data") or {}
            if isinstance(rel_data, dict):
                org_id = rel_data.get("id") or org_id

        org_id = org_id or attrs.get("organizationId") or attrs.get("orgId")

        # Try included organizations
        for item in payload.get("included", []) or []:
            if item.get("type") in ("organization", "org") and (
                org_id is None or str(item.get("id")) == str(org_id)
            ):
                org_name = item.get("attributes", {}).get("name") or org_name
                if org_id is None:
                    org_id = item.get("id")
                break

        # Fallback to meta if provided
        meta_org = (payload.get("meta") or {}).get("org") or {}
        if not org_id and meta_org.get("id"):
            org_id = meta_org.get("id")
        if not org_name and meta_org.get("name"):
            org_name = meta_org.get("name")

        return {
            "user_id": str(user_id) if user_id is not None else None,
            "email": email,
            "name": name,
            "org_id": str(org_id) if org_id is not None else None,
            "org_name": org_name,
            "role": role,
        }

    def probe_capabilities(self) -> List[CapabilityCheck]:
        """Probe key endpoints to validate scope coverage."""
        probes = [
            ("sequences", f"{API_BASE}/sequences", {"page[size]": 1}, None),
            ("prospects", f"{API_BASE}/prospects", {"page[size]": 1}, None),
            ("mailboxes", f"{API_BASE}/mailboxes", {"page[size]": 1}, None),
            ("users", f"{API_BASE}/users/me", None, API_BASE),
        ]

        try:
            headers = self._headers()
        except (OutreachAuthError, OutreachError, AuthenticationError) as exc:
            return [CapabilityCheck(name=name, status="error", detail=str(exc)) for name, *_ in probes]

        checks: List[CapabilityCheck] = []
        for name, url, params, fallback_url in probes:
            checks.append(
                self._probe_endpoint(
                    name,
                    url,
                    headers=headers,
                    params=params,
                    fallback_url=fallback_url,
                )
            )
        return checks

    def _probe_endpoint(
        self,
        name: str,
        url: str,
        *,
        headers: Dict[str, str],
        params: Dict[str, Any] | None = None,
        fallback_url: str | None = None,
    ) -> CapabilityCheck:
        try:
            resp = self._get(url, headers=headers, params=params, allow_statuses=(403, 404, 405))
            if resp.status_code in (404, 405) and fallback_url:
                resp = self._get(fallback_url, headers=headers, allow_statuses=(403,))
            if resp.status_code == 403:
                detail = f"{resp.status_code} {resp.reason}".strip()
                return CapabilityCheck(name=name, status="denied", detail=detail)
            return CapabilityCheck(name=name, status="ok", detail="")
        except AuthenticationError as exc:
            return CapabilityCheck(name=name, status="error", detail=str(exc))
        except (RateLimitError, IntegrationError, TransientError) as exc:
            return CapabilityCheck(name=name, status="error", detail=str(exc))

    def list_sequences(
        self,
        enabled_only: bool = True,
        page_size: int = 50,
        max_results: int = 1000,
    ) -> List[OutreachSequence]:
        """List available sequences."""
        sequences: List[OutreachSequence] = []
        url = f"{API_BASE}/sequences"
        params: Dict[str, Any] = {"page[size]": page_size}

        # Note: Outreach API doesn't support filter[enabled], filter client-side

        while url and len(sequences) < max_results:
            try:
                resp = self._get(url, headers=self._headers(), params=params)
                data = resp.json()
            except AuthenticationError as exc:
                raise OutreachAuthError(str(exc)) from exc
            except (RateLimitError, IntegrationError, TransientError) as exc:
                raise OutreachError(str(exc)) from exc

            for item in data.get("data", []):
                attrs = item.get("attributes", {})
                seq = OutreachSequence(
                    id=str(item.get("id")),
                    name=attrs.get("name", ""),
                    enabled=attrs.get("enabled", False),
                    sequence_type=attrs.get("sequenceType", ""),
                    step_count=attrs.get("stepCount", 0),
                )
                # Client-side filtering for enabled sequences
                if enabled_only and not seq.enabled:
                    continue
                sequences.append(seq)

            # Handle pagination
            links = data.get("links", {})
            url = links.get("next")
            params = {}  # Clear params for subsequent requests

        return sequences[:max_results]

    def get_prospect(self, prospect_id: str) -> Optional[OutreachProspect]:
        """Get a prospect by ID."""
        try:
            resp = self._get(f"{API_BASE}/prospects/{prospect_id}", headers=self._headers())
            data = resp.json()
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except IntegrationError:
            return None
        except (RateLimitError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        return self._parse_prospect(data.get("data", {}))

    def find_prospect_by_email(self, email: str) -> Optional[OutreachProspect]:
        """Find a prospect by email address."""
        params = {"filter[emails]": email}
        try:
            resp = self._get(f"{API_BASE}/prospects", headers=self._headers(), params=params)
            data = resp.json()
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        prospects = data.get("data", [])
        if not prospects:
            return None
        return self._parse_prospect(prospects[0])

    def create_prospect(self, prospect: OutreachProspect) -> OutreachProspect:
        """Create a new prospect."""
        try:
            resp = self._post(
                f"{API_BASE}/prospects",
                headers=self._headers(),
                json=prospect.to_api_format(),
            )
            data = resp.json()
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        return self._parse_prospect(data.get("data", {}))

    def add_to_sequence(
        self,
        prospect_id: str,
        sequence_id: str,
        mailbox_id: Optional[str] = None,
    ) -> SequenceEnrollment:
        """
        Add a prospect to a sequence.

        Creates a sequenceState resource to enroll the prospect.
        """
        payload = {
            "data": {
                "type": "sequenceState",
                "relationships": {
                    "prospect": {
                        "data": {"type": "prospect", "id": prospect_id}
                    },
                    "sequence": {
                        "data": {"type": "sequence", "id": sequence_id}
                    },
                },
            }
        }

        if mailbox_id:
            payload["data"]["relationships"]["mailbox"] = {
                "data": {"type": "mailbox", "id": mailbox_id}
            }

        try:
            resp = self._post(
                f"{API_BASE}/sequenceStates",
                headers=self._headers(),
                json=payload,
                allow_statuses=(409, 422),  # Conflict (already enrolled), validation error
            )
            data = resp.json()
        except AuthenticationError as exc:
            raise OutreachAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError, TransientError) as exc:
            raise OutreachError(str(exc)) from exc

        if resp.status_code == 409:
            return SequenceEnrollment(
                prospect_id=prospect_id,
                sequence_id=sequence_id,
                status="already_enrolled",
            )

        if resp.status_code == 422:
            errors = data.get("errors", [])
            error_msg = errors[0].get("detail") if errors else "Validation error"
            return SequenceEnrollment(
                prospect_id=prospect_id,
                sequence_id=sequence_id,
                status="failed",
                error=error_msg,
            )

        state_id = data.get("data", {}).get("id")
        return SequenceEnrollment(
            prospect_id=prospect_id,
            sequence_id=sequence_id,
            sequence_state_id=str(state_id) if state_id else None,
            status="enrolled",
        )

    def add_to_sequence_bulk(
        self,
        prospects: List[OutreachProspect],
        sequence_id: str,
        mailbox_id: Optional[str] = None,
        create_if_missing: bool = True,
        pacing_profile: PacingProfile | None = None,
    ) -> BatchOutcome:
        """
        Add multiple prospects to a sequence.

        For each prospect:
        1. Look up by email
        2. Create if not found (if create_if_missing=True)
        3. Enroll in sequence
        """
        outcome = BatchOutcome()
        profile = pacing_profile or get_outreach_profile("safe")
        pacer = BatchPacer(profile)

        for batch in pacer.chunk(prospects):
            for prospect in batch:
                if not prospect.email:
                    outcome.failed.append({
                        "email": None,
                        "error": "No email provided",
                        **prospect.to_dict(),
                    })
                    continue

                try:
                    # Find or create prospect
                    existing = self.find_prospect_by_email(prospect.email)
                    if existing:
                        prospect_id = existing.id
                    elif create_if_missing:
                        created = self.create_prospect(prospect)
                        prospect_id = created.id
                    else:
                        outcome.failed.append({
                            "email": prospect.email,
                            "error": "Prospect not found",
                            **prospect.to_dict(),
                        })
                        continue

                    # Enroll in sequence
                    enrollment = self.add_to_sequence(prospect_id, sequence_id, mailbox_id)

                    if enrollment.status in ("enrolled", "already_enrolled"):
                        outcome.succeeded.append({
                            "email": prospect.email,
                            "prospect_id": prospect_id,
                            "status": enrollment.status,
                            **enrollment.to_dict(),
                        })
                    else:
                        outcome.failed.append({
                            "email": prospect.email,
                            "prospect_id": prospect_id,
                            "error": enrollment.error,
                            **enrollment.to_dict(),
                        })

                except OutreachError as exc:
                    if (
                        isinstance(exc.__cause__, RateLimitError)
                        and pacer.profile.name == "turbo"
                        and not pacer.is_downshifted
                    ):
                        logger.warning("Outreach rate limited; downshifting to safe pacing.")
                        pacer.downshift()
                    outcome.retryable_failed.append({
                        "email": prospect.email,
                        "error": str(exc),
                        **prospect.to_dict(),
                    })

            pacer.pace()

        return outcome

    def _parse_prospect(self, data: Dict[str, Any]) -> OutreachProspect:
        """Parse API response into OutreachProspect."""
        attrs = data.get("attributes", {})
        emails = attrs.get("emails", [])
        phones = attrs.get("homePhones", []) or attrs.get("mobilePhones", [])

        return OutreachProspect(
            id=str(data.get("id")) if data.get("id") else None,
            email=emails[0] if emails else None,
            first_name=attrs.get("firstName"),
            last_name=attrs.get("lastName"),
            title=attrs.get("title"),
            company_name=attrs.get("company"),
            linkedin_url=attrs.get("linkedInUrl"),
            phone=phones[0] if phones else None,
            custom=attrs.get("custom", {}),
        )


# Convenience functions
def enroll_from_csv(
    csv_path: str,
    sequence_id: str,
    email_col: str = "email",
    firstname_col: str = "firstname",
    lastname_col: str = "lastname",
    company_col: str = "company_name",
    title_col: str = "title",
    linkedin_col: str = "linkedin_url",
    phone_col: str = "phone",
    create_if_missing: bool = True,
    pacing_profile: PacingProfile | None = None,
) -> BatchOutcome:
    """
    Enroll prospects from a CSV file into a sequence.

    Args:
        csv_path: Path to input CSV
        sequence_id: Target sequence ID
        email_col: Column name for email
        firstname_col: Column name for first name
        lastname_col: Column name for last name
        company_col: Column name for company
        title_col: Column name for title
        linkedin_col: Column name for LinkedIn URL
        phone_col: Column name for phone
        create_if_missing: Create prospect if not found in Outreach

    Returns:
        BatchOutcome with succeeded, failed, and retryable_failed lists
    """
    import csv
    from pathlib import Path

    path = Path(csv_path)
    if not path.exists():
        raise OutreachError(f"CSV file not found: {csv_path}")

    prospects: List[OutreachProspect] = []
    with open(path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            email = row.get(email_col, "").strip()
            if not email:
                continue

            prospect = OutreachProspect(
                email=email,
                first_name=row.get(firstname_col, "").strip() or None,
                last_name=row.get(lastname_col, "").strip() or None,
                company_name=row.get(company_col, "").strip() or None,
                title=row.get(title_col, "").strip() or None,
                linkedin_url=row.get(linkedin_col, "").strip() or None,
                phone=row.get(phone_col, "").strip() or None,
            )
            prospects.append(prospect)

    if not prospects:
        raise OutreachError("No valid prospects found in CSV.")

    client = OutreachClient()
    return client.add_to_sequence_bulk(
        prospects=prospects,
        sequence_id=sequence_id,
        create_if_missing=create_if_missing,
        pacing_profile=pacing_profile,
    )


__all__ = [
    "OutreachError",
    "OutreachAuthError",
    "OutreachScopeError",
    "OutreachSequence",
    "OutreachProspect",
    "SequenceEnrollment",
    "MissingScopesResult",
    "OutreachClient",
    "enroll_from_csv",
    "run_oauth_flow",
    "authenticate",
    "save_manual_credentials",
    "OUTREACH_SCOPES",
    "SCOPE_PROFILES",
    "get_active_scopes",
    "check_scope_coverage",
]
