"""
Centralized Salesforce Gateway - Single source of truth for all SF operations.
Handles token refresh, rate limiting, retries, circuit breaking, and security.
"""

import asyncio
import contextvars
import json
import os
import random
import time
import uuid
from collections import deque
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Any, List
from enum import Enum
import logging
from dataclasses import dataclass
import httpx
import re
import inspect
from urllib.parse import urlparse

try:
    import redis.asyncio as redis_async
except Exception:  # pragma: no cover - optional dependency
    redis_async = None

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..models import Integration, IntegrationStatus, IntegrationProvider
from ..integrations.salesforce import SalesforceClient
from ..security.crypto import decrypt_str, encrypt_str
from .. import settings
from .soql_query_policy import safe_relationship_object_for_alias

log = logging.getLogger(__name__)


class SFErrorType(Enum):
    """Salesforce error taxonomy for retry logic"""

    AUTH_EXPIRED = "auth_expired"  # 401 - refresh and retry
    RATE_LIMIT = "rate_limit"  # 429/403 - backoff
    TRANSIENT = "transient"  # 5xx - retry with backoff
    PERMANENT = "permanent"  # 4xx - fail fast
    UNKNOWN = "unknown"


@dataclass
class CircuitBreakerState:
    """Circuit breaker for failing tenants"""

    failures: int = 0
    last_failure: Optional[datetime] = None
    state: str = "closed"  # closed, open, half_open
    last_success: Optional[datetime] = None
    half_open_attempt: bool = False


def _split_csv_soql(section: str) -> List[str]:
    tokens: List[str] = []
    buf: List[str] = []
    depth = 0
    for ch in section:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth = max(0, depth - 1)
        if ch == "," and depth == 0:
            token = "".join(buf).strip()
            if token:
                tokens.append(token)
            buf = []
            continue
        buf.append(ch)
    token = "".join(buf).strip()
    if token:
        tokens.append(token)
    return tokens


def parse_soql_touchpoints(query: str) -> Dict[str, Dict[str, set]]:
    """
    Rough parser to extract objects and fields touched by a SOQL query.
    Returns {"objects": {"Opportunity", ...}, "fields": {"Opportunity": {"Id","Name"}, ...}}
    """
    if not query:
        return {"objects": set(), "fields": {}}

    objects: set[str] = set()
    fields: Dict[str, set[str]] = {}

    def _strip_quoted_literals(text: str) -> str:
        if not text:
            return text
        # Remove single-quoted and double-quoted literals to avoid false field detection.
        text = re.sub(r"'(?:\\'|[^'])*'", "''", text)
        text = re.sub(r'"(?:\\"|[^"])*"', '""', text)
        return text

    select_match = re.search(
        r"select\s+(.*?)\s+from\s", query, re.IGNORECASE | re.DOTALL
    )
    from_match = re.search(r"\bfrom\s+([a-zA-Z0-9_]+)", query, re.IGNORECASE)
    primary_object = None
    if from_match:
        primary_object = from_match.group(1)
        objects.add(primary_object)
        fields.setdefault(primary_object, set())

    def add_field(obj: Optional[str], field_expr: str) -> None:
        if not obj:
            return
        fields.setdefault(obj, set()).add(field_expr)

    def _unwrap_aggregate_argument(token: str) -> Optional[str]:
        normalized = str(token or "").strip()
        if not normalized:
            return None
        agg_match = re.fullmatch(
            r"(?i)(?:sum|avg|count|min|max|count_distinct)\s*\(\s*([A-Za-z_][A-Za-z0-9_.]*|\*)\s*\)",
            normalized,
        )
        if not agg_match:
            return normalized
        inner = agg_match.group(1).strip()
        if inner == "*":
            return None
        return inner

    def _track_field(token: str) -> None:
        token = _unwrap_aggregate_argument(token)
        if not token:
            return
        if "." in token:
            prefix, rest = token.split(".", 1)
            if prefix[:1].isupper():
                objects.add(prefix)
                add_field(prefix, rest)
                add_field(primary_object, token)
            else:
                add_field(primary_object, token)
        else:
            add_field(primary_object, token)

    if select_match and primary_object:
        for token in _split_csv_soql(select_match.group(1)):
            token = re.split(r"\s+(?:as\s+)?\w+$", token, flags=re.IGNORECASE)[
                0
            ].strip()
            if not token:
                continue
            if token.upper().startswith("SELECT "):
                continue  # ignore nested subqueries for now
            _track_field(token)

    where_match = re.search(
        r"\bwhere\b(.*?)(\bgroup\s+by\b|\border\s+by\b|\blimit\b|$)",
        query,
        re.IGNORECASE | re.DOTALL,
    )
    if where_match and primary_object:
        clause = _strip_quoted_literals(where_match.group(1))
        # SQL keywords and SOQL date literals to skip (these are not field names)
        sql_keywords = {
            "and",
            "or",
            "not",
            "in",
            "like",
            "null",
            "true",
            "false",
            "asc",
            "desc",
            "limit",
            "offset",
            "group",
            "order",
            "by",
            "having",
            # SOQL date literals
            "yesterday",
            "today",
            "tomorrow",
            "last_week",
            "this_week",
            "next_week",
            "last_month",
            "this_month",
            "next_month",
            "last_90_days",
            "next_90_days",
            "last_n_days",
            "next_n_days",
            "last_n_weeks",
            "next_n_weeks",
            "last_n_months",
            "next_n_months",
            "last_n_quarters",
            "next_n_quarters",
            "this_quarter",
            "last_quarter",
            "next_quarter",
            "this_year",
            "last_year",
            "next_year",
            "last_n_years",
            "next_n_years",
            "this_fiscal_quarter",
            "last_fiscal_quarter",
            "next_fiscal_quarter",
            "last_n_fiscal_quarters",
            "next_n_fiscal_quarters",
            "this_fiscal_year",
            "last_fiscal_year",
            "next_fiscal_year",
            "last_n_fiscal_years",
            "next_n_fiscal_years",
        }
        for candidate in re.findall(r"([a-zA-Z0-9_\.]+)", clause):
            lower_candidate = candidate.lower()
            if lower_candidate in sql_keywords or lower_candidate.isdigit():
                continue
            _track_field(candidate)

    order_match = re.search(
        r"\border\s+by\s+(.*?)(\blimit\b|$)", query, re.IGNORECASE | re.DOTALL
    )
    if order_match and primary_object:
        for token in _split_csv_soql(order_match.group(1)):
            token = token.strip()
            if not token:
                continue
            token = token.split()[0]  # strip DESC/ASC
            _track_field(token)

    normalized_fields: Dict[str, set[str]] = {}
    for obj, obj_fields in fields.items():
        normalized_fields[obj] = {f.strip() for f in obj_fields if f.strip()}
    return {"objects": objects, "fields": normalized_fields}


class SalesforceGateway:
    """
    The ONLY way to talk to Salesforce.
    All features must use this gateway - no exceptions.
    """

    _instances: Dict[str, "SalesforceGateway"] = {}
    _global_lock = asyncio.Lock()
    _instance_ttl = timedelta(hours=2)  # Cache eviction after 2 hours
    _redis_rate_client = None
    _SOBJECT_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
    _SF_ID_RE = re.compile(r"^[A-Za-z0-9]{15}([A-Za-z0-9]{3})?$")
    _SF_ALLOWED_HOST_SUFFIXES = ("salesforce.com", "my.salesforce.com", "force.com")

    # Security: Blocked patterns for sensitive data
    BLOCKED_PATTERNS = [
        r"select\s+.*(password|secret|token|key|akia)[^;]*\sfrom",
        r"\bfrom\s+(case|emailmessage|attachment|content(version|document))\b",
        r"\bwhere\s+.*\b(body|textbody|description)\b\s+like\s+.*%",  # wide LIKE scans
    ]

    # Security: Allowed objects and fields
    ALLOWED_OBJECTS = {
        # For core CRM objects we rely on Salesforce FLS checks rather than a static field allowlist.
        # Using an empty set keeps object-level monitoring while permitting any field that passes FLS.
        "account": set(),
        "lead": set(),
        "contact": set(),
        "opportunity": set(),
        "case": set(),
        # Support creating AccountContactRelation when enabled
        "accountcontactrelation": {
            "id",
            "accountid",
            "contactid",
            "isactive",
            "isdirect",
        },
        "campaign": set(),
        "campaignmember": {
            "leadid",
            "contactid",
            "campaignid",
            "status",
        },  # For L2A campaign member queries/writes
        "user": {
            "id",
            "name",
            "firstname",
            "lastname",
            "alias",
            "email",
            "isactive",
        },  # For Owner relationship traversals (Opportunity.Owner.Name, etc.)
        "organization": {"id", "organizationtype", "ismulticurrencyenabled"},  # For org info
        "currencytype": {"isocode", "isactive", "iscorporate"},
        "opportunitylineitem": set(),
        "report": {
            "id",
            "name",
            "foldername",
            "lastrundate",
            "lastmodifieddate",
            "format",
            "description",
            "ownerid",
            "developername",
        },  # For Report Browser
    }

    @classmethod
    def _augment_allowlist_with_config(cls):
        """Dynamically add configured writeback field to allowed Lead fields"""
        try:
            from ...saas import settings

            wb = getattr(settings, "L2A_WRITEBACK_FIELD", "") or ""
            wb = wb.lower().strip()
            if wb:
                cls.ALLOWED_OBJECTS.setdefault("lead", set()).add(wb)
                log.info(f"Added writeback field '{wb}' to allowed Lead fields")
        except Exception as e:
            log.warning(f"Could not augment allowlist with writeback field: {e}")

    async def _validate_writeback_exists(self):
        """Validate that configured writeback field exists on Lead object and references Account"""
        try:
            from ...saas import settings

            wb = getattr(settings, "L2A_WRITEBACK_FIELD", "") or ""
            if not wb:
                return  # No writeback field configured

            # Ensure we have a client
            await self._ensure_client()
            if not self._client:
                log.warning("Cannot validate writeback field - no Salesforce client")
                return

            # Get Lead fields from Salesforce
            try:
                lead_desc = await self._execute("describe_object", "Lead")
                field_dict = {f["name"].lower(): f for f in lead_desc.get("fields", [])}

                wb_lower = wb.lower()
                if wb_lower not in field_dict:
                    log.error(
                        f"Configured writeback field '{wb}' not found on Lead object"
                    )
                    log.error(
                        f"Available Lead fields: {sorted(field_dict.keys())[:20]}..."
                    )  # Show first 20
                    # Don't raise exception - just log warning
                else:
                    # Type-guard: Ensure it's a reference to Account
                    field_info = field_dict[wb_lower]
                    field_type = field_info.get("type", "")
                    reference_to = field_info.get("referenceTo", [])

                    if field_type != "reference":
                        log.error(
                            f"SECURITY WARNING: Writeback field '{wb}' is not a reference field!"
                        )
                        log.error(
                            f"Field type is '{field_type}' - expected 'reference' to Account"
                        )
                        log.error(
                            "This could expose sensitive data. Please use a proper Account lookup field."
                        )
                    elif "Account" not in reference_to:
                        log.error(
                            f"SECURITY WARNING: Writeback field '{wb}' does not reference Account!"
                        )
                        log.error(f"Field references: {reference_to}")
                        log.error(
                            "The writeback field must be a lookup/reference to the Account object."
                        )
                    else:
                        log.info(
                            f"✓ Validated writeback field '{wb}' - proper Account reference field"
                        )
            except Exception as e:
                log.warning(f"Could not validate writeback field existence: {e}")
        except Exception as e:
            log.warning(f"Error in writeback field validation: {e}")

    @classmethod
    async def for_tenant(cls, tenant_id: str, db: AsyncSession) -> "SalesforceGateway":
        """Get or create gateway for a tenant (cached in production only)"""
        # Debug logging for account ID issues
        log.debug("Gateway.for_tenant called for tenant_id=%s", tenant_id)
        log.debug("Gateway cache keys before lookup: %s", list(cls._instances.keys()))
        log.debug("ENV from os.getenv=%s, settings.ENV=%s", os.getenv("ENV", "not set"), settings.ENV)

        # No cache in development by default (configurable for local perf testing)
        # CRITICAL: Use settings.ENV for consistency across the app
        if settings.ENV.lower() in ("dev", "development"):
            if not settings.SF_GATEWAY_CACHE_IN_DEV:
                log.debug(
                    "  DEVELOPMENT MODE - Clearing all cache and returning fresh instance"
                )
                cls._instances.clear()  # Nuclear option - clear everything
                gateway = cls(tenant_id, db)
                await gateway._refresh_org_id()
                return gateway
            log.debug("  DEVELOPMENT MODE - Caching enabled via SF_GATEWAY_CACHE_IN_DEV")

        # Production caching path
        async with cls._global_lock:
            # Clean up stale instances
            for tid, gw in list(cls._instances.items()):
                if datetime.now(timezone.utc) - gw._last_used > cls._instance_ttl:
                    cls._instances.pop(tid, None)

            if tenant_id not in cls._instances:
                cls._instances[tenant_id] = cls(tenant_id, db)

            gateway = cls._instances[tenant_id]
            # CRITICAL: Update db session even for cached gateways
            # AsyncSession is request-scoped and must not be cached
            gateway.db = db
            # Cached gateway instances outlive request-scoped DB sessions.
            # Never carry ORM-bound integration/client objects across requests,
            # or attribute access can trigger async lazy-load failures.
            gateway._integration = None
            gateway._client = None
            gateway._last_integration_load = datetime.min.replace(tzinfo=timezone.utc)
            gateway._last_used = datetime.now(timezone.utc)
            await gateway._refresh_org_id()
            return gateway

    @classmethod
    def safe_decrypt(cls, value: Optional[str]) -> Optional[str]:
        """Decrypt only if encryption is enabled"""
        if not value:
            return None
        if settings.ENCRYPTION_KEY:
            try:
                return decrypt_str(value)
            except Exception as e:
                log.error(f"Decryption failed: {e}")
                return None
        return value

    @classmethod
    def safe_encrypt(cls, value: Optional[str]) -> Optional[str]:
        """Encrypt only if encryption is enabled"""
        if not value:
            return None
        if settings.ENCRYPTION_KEY:
            return encrypt_str(value)
        return value

    @classmethod
    def _is_allowed_salesforce_hostname(cls, hostname: str) -> bool:
        host = (hostname or "").strip().lower().rstrip(".")
        if not host:
            return False
        for suffix in cls._SF_ALLOWED_HOST_SUFFIXES:
            suf = (suffix or "").strip().lower().lstrip(".").rstrip(".")
            if not suf:
                continue
            if host == suf or host.endswith("." + suf):
                return True
        return False

    @classmethod
    def _normalize_salesforce_base_url(cls, value: Optional[str]) -> Optional[str]:
        """Normalize and validate a Salesforce base URL (scheme+host only).

        Defense-in-depth against SSRF and refresh-token exfiltration: we only allow
        https://<salesforce-owned-domain> with no userinfo and no non-443 ports.
        """
        if not value:
            return None
        text = str(value).strip()
        if not text:
            return None
        if "://" not in text:
            text = f"https://{text}"
        try:
            parsed = urlparse(text)
        except Exception:
            return None
        if (parsed.scheme or "").lower() != "https":
            return None
        if parsed.username or parsed.password:
            return None
        if parsed.port and parsed.port != 443:
            return None
        host = (parsed.hostname or "").strip().lower().rstrip(".")
        if not host or not cls._is_allowed_salesforce_hostname(host):
            return None
        return f"https://{host}"

    @staticmethod
    def _ensure_utc(dt: Optional[datetime]) -> Optional[datetime]:
        """Normalize datetimes to timezone-aware UTC for in-memory use."""
        if dt is None:
            return None
        if dt.tzinfo is None:
            return dt.replace(tzinfo=timezone.utc)
        return dt.astimezone(timezone.utc)

    @staticmethod
    def _serialize_timestamp(dt: Optional[datetime]) -> Optional[datetime]:
        """Prepare datetimes for storage in TIMESTAMP WITHOUT TIME ZONE columns."""
        if dt is None:
            return None
        normalized = SalesforceGateway._ensure_utc(dt)
        return normalized.replace(tzinfo=None)

    @classmethod
    def _get_rate_limit_redis(cls):
        """Return shared Redis client for distributed rate limiting."""
        if redis_async is None:
            return None
        redis_url = getattr(settings, "REDIS_URL", None)
        if not redis_url:
            return None
        client = getattr(cls, "_redis_rate_client", None)
        if client is False:
            return None
        if client is None:
            try:
                cls._redis_rate_client = redis_async.from_url(
                    redis_url, decode_responses=False
                )
            except Exception as exc:  # pragma: no cover - redis not always available
                log.warning(
                    "Redis unavailable for Salesforce rate limiting: %s", exc
                )
                cls._redis_rate_client = False
        client = getattr(cls, "_redis_rate_client", None)
        return client if client not in (None, False) else None

    def __init__(self, tenant_id: str, db: AsyncSession):
        log.info(f"=== Gateway.__init__ for tenant_id: {tenant_id} ===")
        self.tenant_id = tenant_id
        self._db_ctx = contextvars.ContextVar(
            f"sf_gateway_db:{tenant_id}", default=None
        )
        self._db_fallback = db
        self.db = db
        # Request/task-scoped state. Cached gateway instances are shared across requests,
        # so ORM-bound integration/client objects must not be global mutable attributes.
        self._client_ctx = contextvars.ContextVar(
            f"sf_gateway_client:{tenant_id}", default=None
        )
        self._integration_ctx = contextvars.ContextVar(
            f"sf_gateway_integration:{tenant_id}", default=None
        )
        self._client: Optional[SalesforceClient] = None
        self._integration: Optional[Integration] = None
        self._writeback_validated = False  # Track if we've validated writeback field

        # Cache frequently accessed integration fields to avoid lazy-loading issues
        self._cached_instance_url: Optional[str] = None
        self._expires_at: Optional[datetime] = None
        self._updated_at: Optional[datetime] = None
        self._org_id: Optional[str] = None

        # Concurrency control
        self._refresh_lock = asyncio.Lock()
        self._semaphore = asyncio.Semaphore(10)  # Max 10 concurrent ops

        # Circuit breaker
        self._breaker = CircuitBreakerState()

        # Token management
        self._last_refresh = datetime.min.replace(tzinfo=timezone.utc)
        self._last_integration_load = datetime.min.replace(
            tzinfo=timezone.utc
        )  # Track integration reload separately
        self._cached_updated_at = None  # Track DB row changes
        self._last_used = datetime.now(timezone.utc)  # For cache eviction

        # Rate limiting
        self._api_calls = deque(maxlen=15000)  # Local fallback buffer
        self._rl_lock = asyncio.Lock()  # Protect local rate limit operations
        self._remaining_api_calls = 15000  # From SF headers
        self._last_limit_info: Optional[str] = None
        self._parsed_api_usage: Optional[tuple] = None  # Cache (used, total) tuple
        self._parsed_at: Optional[float] = None  # When we last parsed
        self._rate_limit_key = f"sf:rate:{tenant_id}"

        # Security tracking
        self._query_history = deque(maxlen=100)
        self._security_events = deque(maxlen=1000)
        self._blocked_count = 0

    @property
    def db(self) -> AsyncSession:
        db = self._db_ctx.get()
        return db or self._db_fallback

    @db.setter
    def db(self, value: AsyncSession) -> None:
        self._db_fallback = value
        self._db_ctx.set(value)

    @property
    def _client(self) -> Optional[SalesforceClient]:
        return self._client_ctx.get()

    @_client.setter
    def _client(self, value: Optional[SalesforceClient]) -> None:
        self._client_ctx.set(value)

    @property
    def _integration(self) -> Optional[Integration]:
        return self._integration_ctx.get()

    @_integration.setter
    def _integration(self, value: Optional[Integration]) -> None:
        self._integration_ctx.set(value)

    def _maybe_attach_db_callback(self, method, call_kwargs: dict) -> dict:
        """Attach persistence callback when the client method supports it."""
        if "db_callback" in call_kwargs:
            return call_kwargs
        try:
            sig = inspect.signature(method)
        except (ValueError, TypeError):
            return call_kwargs

        param = sig.parameters.get("db_callback")
        if param and param.kind in (
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
            inspect.Parameter.KEYWORD_ONLY,
            inspect.Parameter.VAR_KEYWORD,
        ):
            call_kwargs["db_callback"] = self._persist_integration
        return call_kwargs

    async def _persist_integration(self, integration: Optional[Integration]) -> None:
        """Persist refreshed tokens back to the database and refresh caches."""
        if not integration:
            return

        integration.updated_at = datetime.utcnow()
        # Snapshot before commit so cache refresh doesn't rely on ORM attribute
        # access across commit/expiration boundaries.
        integration_dict = getattr(integration, "__dict__", {}) or {}
        cached_expires_at = self._ensure_utc(integration_dict.get("expires_at"))
        cached_updated_at = self._ensure_utc(integration_dict.get("updated_at"))
        cached_instance_url = integration_dict.get("instance_url")

        self.db.add(integration)
        try:
            await self.db.commit()
        except Exception as exc:
            await self.db.rollback()
            log.error(
                "Failed to persist Salesforce integration for %s: %s",
                self.tenant_id,
                exc,
            )
            raise

        self._integration = integration
        self._expires_at = cached_expires_at
        self._updated_at = cached_updated_at
        self._cached_instance_url = cached_instance_url
        self._cached_updated_at = self._serialize_timestamp(self._updated_at)
        self._last_integration_load = datetime.now(timezone.utc)
        log.debug("Persisted Salesforce token refresh for %s", self.tenant_id)

    @property
    def org_id(self) -> Optional[str]:
        """Return cached Salesforce org ID (if available)."""
        return self._org_id

    async def _refresh_org_id(self) -> None:
        """Best-effort load Salesforce org ID from OrgProfile."""
        if self._org_id:
            return
        try:
            from ..models import OrgProfile

            result = await self.db.execute(
                select(OrgProfile.org_id).where(
                    OrgProfile.account_id == self.tenant_id
                )
            )
            org_id = result.scalar_one_or_none()
            if org_id:
                self._org_id = str(org_id)
        except Exception as exc:  # pragma: no cover - defensive
            log.warning(
                "Failed to load Salesforce org id for %s: %s", self.tenant_id, exc
            )

    # ========== Public API ==========

    @property
    def instance_url(self) -> Optional[str]:
        """Return the cached Salesforce instance URL."""
        return self._cached_instance_url

    async def soql(
        self,
        query: str,
        *,
        admin_override: bool = False,
        user: str = None,
        ip: str = None,
        user_is_admin: bool = False,
        plan_allowlist: Optional[Dict[str, Any]] = None,
        fls_guard: bool = True,
    ) -> dict:
        """Execute SOQL query with security checks and automatic retry"""
        # Build context for security check
        ctx = {
            "admin_override": admin_override,
            "user": user,
            "ip": ip,
            "user_is_admin": user_is_admin,
            "plan_allowlist": plan_allowlist or {},
        }

        # Security check before execution
        touchpoints = await self._security_check(query, ctx)

        if plan_allowlist:
            self._enforce_allowlist(plan_allowlist, touchpoints)

        if fls_guard:
            invalid = await self._find_inaccessible_fields(touchpoints)
            if invalid:
                raise SalesforceFieldAccessError(invalid_fields=invalid)

        # Log successful admin override for audit trail
        if admin_override and user_is_admin:
            await self._log_security_event(
                "ADMIN_OVERRIDE_ALLOWED", "Sensitive query allowed by admin", ctx, query
            )

        # Track query for audit
        self._query_history.append(
            {
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "user": user,
                "ip": ip,
                "query": query[:1000],
                "admin": admin_override,
            }
        )

        # Call original execution
        return await self._execute("soql", query)

    async def tooling_query(self, query: str) -> dict:
        """Execute Tooling API SOQL query."""
        return await self._execute("tooling_query", query)

    async def describe(self, sobject: str) -> dict:
        """Describe Salesforce object"""
        await self._validate_read_access(sobject)
        return await self._execute("describe_object", sobject)

    async def ui_api_picklist_values_by_record_type(
        self, object_api_name: str, record_type_id: str
    ) -> dict:
        """Fetch UI API picklist values for a specific record type."""
        return await self._execute(
            "ui_api_picklist_values_by_record_type", object_api_name, record_type_id
        )

    async def deploy_metadata(self, zip_bytes: bytes) -> dict:
        """Deploy metadata zip via Salesforce Metadata API."""
        return await self._execute("deploy_metadata", zip_bytes)

    async def check_deploy_status(self, deployment_id: str) -> dict:
        """Check status of an async Metadata API deployment."""
        return await self._execute("check_deploy_status", deployment_id)

    async def describe_global(self) -> dict:
        """Return Describe Global payload with org metadata."""
        return await self._execute("describe_global")

    async def bulk_create(
        self, sobject: str, records: list, *, job_callback=None
    ) -> dict:
        """Bulk create records"""
        await self._validate_write_access(sobject, records)
        return await self._execute(
            "bulk_create", sobject, records, job_callback=job_callback
        )

    async def bulk_update(
        self, sobject: str, records: list, *, job_callback=None
    ) -> dict:
        """Bulk update records (all records must have Id field)"""
        await self._validate_write_access(sobject, records)
        return await self._execute(
            "bulk_update", sobject, records, job_callback=job_callback
        )

    async def bulk_upsert(
        self, sobject: str, records: list, external_id: str, *, job_callback=None
    ) -> dict:
        """Bulk upsert with retry"""
        await self._validate_write_access(sobject, records)
        return await self._execute(
            "bulk_upsert", sobject, records, external_id, job_callback=job_callback
        )

    async def bulk_job_results(self, job_id: str) -> dict:
        """Fetch results for an existing Bulk API ingest job."""
        return await self._execute("bulk_job_results", job_id)

    async def composite(self, payload: dict) -> dict:
        """Execute a REST Composite API request."""
        await self._validate_composite_payload(payload)
        return await self._execute("composite", payload)

    async def merge_records(
        self,
        sobject: str,
        master_id: str,
        duplicate_ids: list,
        preserve_fields: dict | None = None,
    ) -> dict:
        """Merge duplicate records (pass-through, supports preserve_fields)."""
        ctx = {"user": None, "ip": None}
        await self._validate_record_id(
            master_id, event_type="BLOCKED_RECORD_ID", context=ctx
        )
        for dup_id in duplicate_ids or []:
            await self._validate_record_id(
                dup_id, event_type="BLOCKED_RECORD_ID", context=ctx
            )
        await self._validate_write_access(sobject, preserve_fields or {})
        return await self._execute(
            "merge_records", sobject, master_id, duplicate_ids, preserve_fields
        )

    async def update(
        self,
        sobject: str,
        record_id: str,
        data: dict,
        *,
        enforce_lead_guard: bool = True,
    ) -> dict:
        """Update single record; forwards to update_record with optional guard override."""
        return await self.update_record(
            sobject,
            record_id,
            data,
            enforce_lead_guard=enforce_lead_guard,
        )

    async def create(self, sobject: str, data: dict) -> dict:
        """Create a single record (thin wrapper)."""
        await self._validate_write_access(sobject, data)
        return await self._execute("create_record", sobject, data)

    async def delete(self, sobject: str, record_id: str) -> bool:
        """Delete single record"""
        await self._validate_record_id(
            record_id, event_type="BLOCKED_RECORD_ID", context={"user": None, "ip": None}
        )
        await self._validate_write_access(sobject, {})
        return await self._execute("delete_record", sobject, record_id)

    async def ping(self) -> bool:
        """Health check - lightweight SOQL"""
        try:
            await self.soql("SELECT Id FROM Organization LIMIT 1")
            return True
        except Exception as e:
            log.debug(f"Ping failed for {self.tenant_id}: {e}")
            return False

    async def get_status(self, *_, **__) -> dict:
        """Get connection status with proactive refresh"""
        await self._ensure_integration()

        if not self._integration:
            return {
                "connected": False,
                "needs_reconnect": False,
                "reason": "not_configured",
                "message": "No Salesforce integration configured",
            }

        # Check if integration is disconnected
        integration_dict = self._integration.__dict__ or {}
        integration_status = integration_dict.get("status")
        log.info(f"Integration status for {self.tenant_id}: {integration_status}")
        if integration_status == IntegrationStatus.DISCONNECTED:
            return {
                "connected": False,
                "needs_reconnect": False,
                "reason": "disconnected",
                "message": "Salesforce integration has been disconnected",
            }

        # Check persisted reconnect flag
        reconnect_cfg = integration_dict.get("config")
        reconnect_cfg = reconnect_cfg if isinstance(reconnect_cfg, dict) else {}
        needs_reconnect_flag = reconnect_cfg.get("needs_reconnect") is True

        # Check if token needs refresh
        needs_refresh = False
        expires_in_sec = None

        if self._expires_at:
            time_left = (self._expires_at - datetime.now(timezone.utc)).total_seconds()
            expires_in_sec = max(0, int(time_left))

            # Proactively refresh if < 5 minutes left
            if time_left < 300 and not needs_reconnect_flag:
                if await self._refresh_token():
                    # Recalculate after refresh
                    time_left = (
                        (self._expires_at or datetime.now(timezone.utc))
                        - datetime.now(timezone.utc)
                    ).total_seconds()
                    expires_in_sec = max(0, int(time_left))
                else:
                    needs_refresh = True

        # Check circuit breaker state
        is_open = self._breaker.state == "open"

        return {
            "connected": not (needs_reconnect_flag or needs_refresh or is_open),
            "needs_reconnect": needs_reconnect_flag or needs_refresh or is_open,
            "instance_url": self._cached_instance_url,  # Use cached value to avoid lazy-loading
            "expires_in_sec": expires_in_sec,
            "last_refresh_ts": self._last_refresh.isoformat()
            if self._last_refresh != datetime.min.replace(tzinfo=timezone.utc)
            else None,
            "circuit_breaker": self._breaker.state,
            "api_calls_remaining": self._remaining_api_calls,
            "reason": reconnect_cfg.get(
                "reconnect_reason", "token_expired" if needs_refresh else "healthy"
            ),
            "message": reconnect_cfg.get(
                "reconnect_message",
                "Token expired" if needs_refresh else "Connection active",
            ),
        }

    # ========== Internal Implementation ==========

    async def _execute(self, operation: str, *args, **kwargs) -> Any:
        """Universal execution with retry, circuit breaker, and rate limiting"""

        # Touch last_used for cache eviction
        self._last_used = datetime.now(timezone.utc)

        # Check circuit breaker
        if self._breaker.state == "open":
            cooldown_s = self._breaker_cooldown_s()
            if (
                datetime.now(timezone.utc) - self._breaker.last_failure
                < timedelta(seconds=cooldown_s)
            ):
                raise SalesforceConnectionError(
                    f"Circuit breaker open for {self.tenant_id}"
                )
            # Try half-open - allow one attempt
            self._breaker.state = "half_open"
            self._breaker.half_open_attempt = True

        # Rate limit check
        if not await self._check_rate_limit():
            raise SalesforceRateLimitError("Rate limit exceeded", retry_after=60)

        # Acquire semaphore for concurrency control
        async with self._semaphore:
            # Ensure we have a valid client
            await self._ensure_client()

            # Execute with retry logic
            for attempt in range(3):
                try:
                    # Get the method from client
                    method = getattr(self._client, operation)
                    call_kwargs = self._maybe_attach_db_callback(method, dict(kwargs))
                    result = await method(*args, **call_kwargs)

                    # Success - update circuit breaker
                    if self._breaker.state == "half_open":
                        self._breaker.state = "closed"
                        self._breaker.failures = 0
                    self._breaker.last_success = datetime.now(timezone.utc)

                    # Track API call and parse rate limit headers
                    await self._track_api_call(result)

                    # Touch last_used again after successful operation
                    self._last_used = datetime.now(timezone.utc)

                    return result

                except Exception as e:
                    error_type = self._classify_error(e)

                    if error_type == SFErrorType.AUTH_EXPIRED:
                        # Refresh and retry once
                        if attempt == 0 and await self._refresh_token():
                            # Rebuild client with fresh token
                            self._client = self._create_client()
                            continue
                        await self._mark_needs_reconnect()

                    elif error_type == SFErrorType.RATE_LIMIT:
                        # Parse retry-after header if available
                        retry_after = getattr(
                            e, "retry_after", None
                        ) or self._calculate_backoff(attempt)
                        log.warning(
                            f"Rate limited for {self.tenant_id}, waiting {retry_after}s"
                        )
                        await asyncio.sleep(retry_after)
                        continue

                    elif error_type == SFErrorType.TRANSIENT:
                        # Retry with exponential backoff + jitter
                        if attempt < 2:
                            backoff = self._calculate_backoff(attempt)
                            log.debug(
                                f"Transient error for {self.tenant_id}, retrying in {backoff}s"
                            )
                            await asyncio.sleep(backoff)
                            continue

                    # Update circuit breaker on failure
                    self._breaker.failures += 1
                    self._breaker.last_failure = datetime.now(timezone.utc)
                    if self._breaker.failures >= 5:
                        self._breaker.state = "open"
                        cooldown_s = self._breaker_cooldown_s()
                        log.error(
                            "Circuit breaker opened for %s after %s failures (cooldown=%ss)",
                            self.tenant_id,
                            self._breaker.failures,
                            cooldown_s,
                        )

                    # If half-open attempt failed, reopen
                    if self._breaker.state == "half_open":
                        self._breaker.state = "open"

                    raise

    async def _ensure_integration(self, *, force_reload: bool = False):
        """Load integration from database if not cached or stale"""
        log.debug("=== _ensure_integration for tenant_id: %s ===", self.tenant_id)
        should_reload = bool(force_reload)
        previous_updated_at = self._cached_updated_at

        # Check if we need to reload
        if not self._integration:
            should_reload = True
            log.debug("No cached integration for %s, will load from DB", self.tenant_id)
        elif datetime.now(timezone.utc) - self._last_integration_load > timedelta(
            seconds=30
        ):
            should_reload = True
            log.debug(
                f"Integration cache stale for {self.tenant_id}, reloading from DB"
            )
        # REMOVED: The broken timestamp comparison that caused infinite loop

        if should_reload:
            query = (
                select(Integration)
                .where(
                    Integration.account_id == self.tenant_id,
                    Integration.provider == IntegrationProvider.SALESFORCE,
                )
                .execution_options(populate_existing=True)
            )
            result = await self.db.execute(query)
            self._integration = result.scalar_one_or_none()

            if self._integration:
                # Force refresh to get latest from DB
                try:
                    await self.db.refresh(self._integration)
                except Exception:
                    # Refresh can fail if object is transient; continue with loaded state
                    pass

                # Normalize timestamps - strip timezone if present
                # Access __dict__ directly to avoid triggering SQLAlchemy lazy-loading in async context
                _dict = self._integration.__dict__
                _expires_at = _dict.get("expires_at")
                _updated_at = _dict.get("updated_at")
                if isinstance(_expires_at, datetime) and _expires_at.tzinfo is not None:
                    self._integration.expires_at = _expires_at.replace(tzinfo=None)
                if isinstance(_updated_at, datetime) and _updated_at.tzinfo is not None:
                    self._integration.updated_at = _updated_at.replace(tzinfo=None)

                # Convert to UTC-aware for in-memory use
                self._expires_at = self._ensure_utc(_dict.get("expires_at"))
                self._updated_at = self._ensure_utc(_dict.get("updated_at"))
                self._cached_updated_at = self._serialize_timestamp(self._updated_at)
                if (
                    previous_updated_at is not None
                    and self._cached_updated_at is not None
                    and self._cached_updated_at != previous_updated_at
                ):
                    # Integration row changed (e.g., token refresh in another request).
                    # Rebuild a client in this request context to avoid stale tokens.
                    self._client = None

                # Cache instance_url to avoid lazy-loading issues
                self._cached_instance_url = _dict.get("instance_url")

                # Diagnostic logging
                has_access_token = bool(_dict.get("access_token"))
                has_refresh_token = bool(_dict.get("refresh_token"))
                integration_status = _dict.get("status")
                log.debug(
                    "Loaded integration for %s: status=%s, updated_at=%s, has_access_token=%s, has_refresh_token=%s",
                    self.tenant_id,
                    integration_status,
                    self._updated_at.isoformat() if self._updated_at else None,
                    has_access_token,
                    has_refresh_token,
                )
            else:
                self._cached_updated_at = None
                self._cached_instance_url = None
                self._expires_at = None
                self._updated_at = None
                log.debug("No integration found for %s", self.tenant_id)

            self._last_integration_load = datetime.now(timezone.utc)

    def _create_client(self) -> SalesforceClient:
        """Create SalesforceClient with custom User-Agent"""
        client = SalesforceClient(self._integration)
        # Set a distinctive User-Agent for security tracking
        # This helps identify gateway traffic vs direct API access
        if not hasattr(client, "_headers"):
            client._headers = {}
        client._headers["User-Agent"] = "FoundryOps-Gateway/1.0"
        return client

    async def _ensure_client(self):
        """Ensure we have a valid SF client with fresh token"""
        await self._ensure_integration()

        if not self._integration:
            raise SalesforceNotConfigured(
                f"No Salesforce integration for {self.tenant_id}"
            )

        # Defense-in-depth: validate instance_url before creating/using a client.
        integration_dict = self._integration.__dict__ or {}
        raw_instance_url = self._cached_instance_url or integration_dict.get(
            "instance_url"
        )
        normalized_instance_url = self._normalize_salesforce_base_url(raw_instance_url)
        if not normalized_instance_url:
            log.error(
                "Blocked unsafe or missing Salesforce instance_url for %s: %r",
                self.tenant_id,
                raw_instance_url,
            )
            await self._mark_needs_reconnect()
            raise SalesforceConnectionError(
                "Invalid Salesforce instance URL. Please reconnect your Salesforce account."
            )
        # Always keep cache populated with the validated URL to avoid ORM reads later.
        self._cached_instance_url = normalized_instance_url
        if normalized_instance_url != raw_instance_url:
            # Keep the in-memory integration safe/normalized; DB persistence occurs on commit.
            try:
                self._integration.instance_url = normalized_instance_url
            except Exception:
                pass

        # CRITICAL FIX: Create client first if we don't have one
        # This avoids unnecessary refresh on first use after OAuth
        if not self._client:
            log.debug("Creating initial client for %s without refresh", self.tenant_id)
            self._client = self._create_client()

            # Validate writeback field exists (only once per instance)
            if not self._writeback_validated:
                await self._validate_writeback_exists()
                self._writeback_validated = True

        # Now check if token needs refresh (5 min buffer)
        needs_refresh = False
        if self._expires_at:
            time_left = (self._expires_at - datetime.now(timezone.utc)).total_seconds()
            needs_refresh = time_left < 300
            if needs_refresh:
                if time_left < 0:
                    log.info(
                        "Token expired %.1fs ago for %s, refreshing now",
                        abs(time_left),
                        self.tenant_id,
                    )
                else:
                    log.info(
                        "Token expiring in %.1fs for %s, refreshing now",
                        time_left,
                        self.tenant_id,
                    )

        # Only refresh if actually needed
        if needs_refresh:
            if not await self._refresh_token():
                raise SalesforceAuthError("Failed to refresh token")
            # Recreate client with refreshed token
            self._client = self._create_client()

    async def _refresh_token(self) -> bool:
        """Refresh OAuth token with deduplication"""
        if not self._integration:
            log.error(f"No integration available for {self.tenant_id}")
            return False

        integration_dict = self._integration.__dict__ or {}
        encrypted_refresh_token = integration_dict.get("refresh_token")
        if not encrypted_refresh_token:
            log.error(f"No refresh token available for {self.tenant_id}")
            return False

        async with self._refresh_lock:
            # Prevent refresh storms
            if datetime.now(timezone.utc) - self._last_refresh < timedelta(seconds=60):
                log.debug(f"Skipping refresh for {self.tenant_id} - too recent")
                # Another request context likely refreshed recently. Force a reload so
                # this context can build a client from current persisted credentials.
                await self._ensure_integration(force_reload=True)
                if self._integration:
                    self._client = self._create_client()
                return self._client is not None

            refresh_token = self.safe_decrypt(encrypted_refresh_token)
            if not refresh_token:
                log.error(f"Refresh token unavailable for {self.tenant_id}")
                await self._mark_needs_reconnect()
                return False

            # Prefer stored auth_host from OAuth flow
            cfg = integration_dict.get("config")
            cfg = cfg if isinstance(cfg, dict) else {}
            token_host = self._normalize_salesforce_base_url(cfg.get("auth_host"))

            if not token_host:
                # Fallback to environment string if present, else default to production
                env = (
                    cfg.get("environment") or getattr(settings, "SF_ENV", "production")
                ).lower()
                token_host = (
                    "https://test.salesforce.com"
                    if env in {"sandbox", "test"}
                    else "https://login.salesforce.com"
                )
                token_host = self._normalize_salesforce_base_url(token_host)

            if not token_host:
                log.error(
                    "No valid Salesforce OAuth host available for %s (config.auth_host=%r)",
                    self.tenant_id,
                    (cfg.get("auth_host") if isinstance(cfg, dict) else None),
                )
                await self._mark_needs_reconnect()
                return False

            log.info(f"Refreshing token for {self.tenant_id} using {token_host}")

            try:
                async with httpx.AsyncClient(timeout=10) as client:
                    response = await client.post(
                        f"{token_host}/services/oauth2/token",
                        data={
                            "grant_type": "refresh_token",
                            "refresh_token": refresh_token,
                            "client_id": getattr(settings, "SALESFORCE_CLIENT_ID", None)
                            or getattr(settings, "SF_CLIENT_ID", None),
                            "client_secret": getattr(
                                settings, "SALESFORCE_CLIENT_SECRET", None
                            )
                            or getattr(settings, "SF_CLIENT_SECRET", None),
                        },
                    )

                if response.is_success:
                    tokens = response.json()
                    encrypted_access_token = self.safe_encrypt(tokens["access_token"])
                    encrypted_refresh_token = integration_dict.get("refresh_token")
                    new_instance_url = self._cached_instance_url or integration_dict.get(
                        "instance_url"
                    )
                    if "instance_url" in tokens:
                        normalized_instance_url = self._normalize_salesforce_base_url(
                            tokens.get("instance_url")
                        )
                        if not normalized_instance_url:
                            log.error(
                                "Blocked invalid instance_url in Salesforce refresh response for %s",
                                self.tenant_id,
                            )
                            await self._mark_needs_reconnect()
                            return False
                        new_instance_url = normalized_instance_url
                    cached_updated_at_new = self._cached_updated_at
                    commit_ok = False
                    expires_at_serialized = None
                    updated_at_serialized = None

                    # Use nested transaction (savepoint) for safety
                    # If commit fails, we don't lose the new refresh token from Salesforce
                    try:
                        async with self.db.begin_nested():  # Creates savepoint
                            # Update access token
                            self._integration.access_token = encrypted_access_token

                            # Update refresh token if provided
                            if "refresh_token" in tokens:
                                encrypted_refresh_token = self.safe_encrypt(
                                    tokens["refresh_token"]
                                )
                                self._integration.refresh_token = encrypted_refresh_token
                                log.info(
                                    f"New refresh token saved for {self.tenant_id}"
                                )

                            # Update instance URL if provided
                            if "instance_url" in tokens:
                                self._integration.instance_url = new_instance_url
                                log.info("Updated instance URL for %s", self.tenant_id)

                            # Update expiry
                            expires_in = int(tokens.get("expires_in", 7200))
                            expires_at_naive = datetime.utcnow() + timedelta(
                                seconds=expires_in
                            )
                            updated_at_naive = datetime.utcnow()
                            expires_at_serialized = expires_at_naive
                            updated_at_serialized = updated_at_naive
                            self._integration.expires_at = expires_at_serialized
                            self._integration.updated_at = updated_at_serialized
                            cached_updated_at_new = updated_at_serialized

                            # Clear reconnect flags
                            current_config = integration_dict.get("config")
                            if isinstance(current_config, dict):
                                current_config.pop("needs_reconnect", None)
                                current_config.pop("reconnect_reason", None)
                                current_config.pop("reconnect_message", None)
                                self._integration.config = current_config

                            # Commit the nested transaction first
                            await self.db.commit()
                            commit_ok = True

                    except Exception as db_error:
                        log.error(
                            "DB commit failed after token refresh for %s: %s",
                            self.tenant_id,
                            db_error,
                        )
                        await self.db.rollback()
                        # Even if DB fails, we still have the tokens in memory
                        # The _client will be updated below, so the session continues working
                        # Next refresh attempt will try to save to DB again
                    finally:
                        # Ensure in-memory state stays UTC-aware and caches are up to date
                        self._integration.access_token = encrypted_access_token
                        self._integration.refresh_token = encrypted_refresh_token
                        self._integration.instance_url = new_instance_url
                        # Persist serialized values on the ORM object to avoid tz-aware flushes
                        if expires_at_serialized is not None:
                            self._integration.expires_at = expires_at_serialized
                        if updated_at_serialized is not None:
                            self._integration.updated_at = updated_at_serialized
                        self._expires_at = self._ensure_utc(expires_at_serialized)
                        self._updated_at = self._ensure_utc(updated_at_serialized)
                        self._cached_instance_url = new_instance_url
                        if commit_ok:
                            self._cached_updated_at = cached_updated_at_new

                    self._last_refresh = datetime.now(timezone.utc)

                    # Rebuild client with fresh token (even if DB commit failed)
                    self._client = self._create_client()

                    log.info(f"Token refreshed successfully for {self.tenant_id}")
                    return True

                # Handle refresh failure
                body = {}
                if response.headers.get("content-type", "").startswith(
                    "application/json"
                ):
                    body = response.json()

                error = body.get("error", "")
                if error in {"invalid_grant", "invalid_client"}:
                    log.error(f"Refresh token invalid for {self.tenant_id}: {error}")
                    await self._mark_needs_reconnect()
                else:
                    log.error(
                        "Token refresh failed for %s: %s - %s",
                        self.tenant_id,
                        response.status_code,
                        body,
                    )
            except Exception as e:
                log.error(f"Token refresh failed for {self.tenant_id}: {e}")

            return False

    async def _mark_needs_reconnect(self):
        """Mark integration as needing reconnect"""
        if self._integration:
            integration_dict = self._integration.__dict__ or {}
            config = integration_dict.get("config")
            if not isinstance(config, dict):
                config = {}
            config["needs_reconnect"] = True
            config["reconnect_reason"] = "token_refresh_failed"
            config["reconnect_message"] = (
                "Please reconnect your Salesforce account"
            )
            self._integration.config = config
            await self.db.commit()
            log.warning(f"Marked {self.tenant_id} as needing reconnect")

    def _classify_error(self, error: Exception) -> SFErrorType:
        """Classify Salesforce errors by status code and error codes"""
        # Prefer status code if available
        status = getattr(error, "status_code", None)
        if status:
            if status == 401:
                return SFErrorType.AUTH_EXPIRED
            if status == 429:
                return SFErrorType.RATE_LIMIT
            if status == 403:
                # Check for specific SF error code
                sf_error = getattr(error, "sf_error_code", "")
                if "REQUEST_LIMIT_EXCEEDED" in sf_error:
                    return SFErrorType.RATE_LIMIT
                return SFErrorType.PERMANENT
            if 500 <= status <= 599:
                return SFErrorType.TRANSIENT
            if 400 <= status <= 499:
                return SFErrorType.PERMANENT

        # Fallback to string matching
        error_str = str(error).lower()
        if "invalid_session_id" in error_str or "401" in error_str:
            return SFErrorType.AUTH_EXPIRED
        elif "request_limit_exceeded" in error_str or "429" in error_str:
            return SFErrorType.RATE_LIMIT
        elif any(code in error_str for code in ["500", "502", "503", "504"]):
            return SFErrorType.TRANSIENT
        elif any(code in error_str for code in ["400", "403", "404"]):
            return SFErrorType.PERMANENT

        return SFErrorType.UNKNOWN

    def _calculate_backoff(self, attempt: int) -> float:
        """Exponential backoff with jitter"""
        base = 2**attempt
        jitter = random.uniform(0, base * 0.1)  # 10% jitter
        return min(base + jitter, 30)  # Cap at 30 seconds

    def _breaker_cooldown_s(self) -> int:
        """Exponential cooldown window for circuit breaker reopening."""
        failures = max(self._breaker.failures, 1)
        extra = max(0, failures - 5)
        return min(30 * (2**extra), 300)

    async def _check_rate_limit(self) -> bool:
        """Check if we're within rate limits"""
        low = self._remaining_api_calls < 10
        near = False
        limit = None
        if self._parsed_api_usage and len(self._parsed_api_usage) == 2:
            try:
                limit = int(self._parsed_api_usage[1])
            except Exception:
                limit = None
        if not limit:
            limit = 15000
        redis_client = self._get_rate_limit_redis()

        if redis_client:
            key = self._rate_limit_key
            now_ms = int(time.time() * 1000)
            cutoff_ms = now_ms - 3600 * 1000
            try:
                await redis_client.zremrangebyscore(key, 0, cutoff_ms)
                count = await redis_client.zcard(key)
                near = count >= max(limit - 100, 0)
                if count >= limit:
                    log.warning(
                        "Salesforce rate limit exceeded for %s (count=%s limit=%s)",
                        self.tenant_id,
                        count,
                        limit,
                    )
                    return False
                await redis_client.expire(key, 3700)
            except Exception as exc:  # pragma: no cover
                log.warning(
                    "Redis rate limiter unavailable for %s, falling back to local buffer: %s",
                    self.tenant_id,
                    exc,
                )
                redis_client = None

        if not redis_client:
            async with self._rl_lock:
                now = datetime.now(timezone.utc)
                cutoff = now - timedelta(hours=1)

                # Clean old entries
                while self._api_calls and self._api_calls[0] < cutoff:
                    self._api_calls.popleft()

                count = len(self._api_calls)
                near = count >= max(limit - 100, 0)
                if count >= limit:
                    log.warning(
                        "Salesforce rate limit exceeded for %s (count=%s limit=%s)",
                        self.tenant_id,
                        count,
                        limit,
                    )
                    return False

        # Sleep outside the lock to avoid blocking other callers
        if low:
            log.warning(
                f"Low API calls remaining for {self.tenant_id}: {self._remaining_api_calls}, backing off 30s"
            )
            await asyncio.sleep(30)
        elif near:
            log.warning(
                f"Approaching hourly limit for {self.tenant_id}, backing off 60s"
            )
            await asyncio.sleep(60)

        if self._remaining_api_calls <= 0:
            log.warning(
                "Salesforce remaining API calls exhausted for %s",
                self.tenant_id,
            )
            return False

        return True

    async def _track_api_call(self, result: Any = None):
        """Track API call and parse rate limit headers"""
        timestamp = datetime.now(timezone.utc)
        async with self._rl_lock:
            self._api_calls.append(timestamp)

            # Try to get Sforce-Limit-Info header
            limit_info = None
            if hasattr(result, "headers"):
                limit_info = result.headers.get("Sforce-Limit-Info")
            elif hasattr(result, "sforce_limit_info"):
                limit_info = result.sforce_limit_info
            elif hasattr(self._client, "last_limit_info"):
                limit_info = self._client.last_limit_info

            if limit_info:
                # Keep raw header for headroom endpoint
                limit_str = None
                try:
                    limit_str = str(limit_info)
                except Exception:
                    pass

                # Only re-parse if limit_info changed
                if limit_str != self._last_limit_info:
                    self._last_limit_info = limit_str

                    # Parse "api-usage=12345/15000" format - be defensive about format variations
                    parsed = False
                    for pattern in [
                        r"api-usage=(\d+)/(\d+)",  # Standard format
                        r"api-usage:\s*(\d+)/(\d+)",  # Colon separator
                        r"(\d+)/(\d+)",  # Just numbers if api-usage prefix missing
                    ]:
                        match = re.search(pattern, limit_str) if limit_str else None
                        if match:
                            try:
                                used = int(match.group(1))
                                total = int(match.group(2))
                                self._remaining_api_calls = max(total - used, 0)
                                self._parsed_api_usage = (
                                    used,
                                    total,
                                )  # Cache the parsed values
                                self._parsed_at = time.time()
                                log.debug(
                                    f"API calls remaining for {self.tenant_id}: {self._remaining_api_calls}"
                                )
                                parsed = True
                                break  # Stop on first successful parse
                            except (ValueError, IndexError) as e:
                                log.debug(
                                    f"Failed to parse API usage with pattern {pattern}: {e}"
                                )
                                continue

                    if not parsed:
                        log.debug(
                            f"Could not parse API usage from limit_info: {limit_str}"
                        )

        redis_client = self._get_rate_limit_redis()
        if redis_client:
            try:
                ts_ms = int(timestamp.timestamp() * 1000)
                member = f"{ts_ms}:{uuid.uuid4().hex}"
                await redis_client.zadd(self._rate_limit_key, {member: ts_ms})
                await redis_client.expire(self._rate_limit_key, 3700)
            except Exception as exc:  # pragma: no cover
                log.warning(
                    "Failed to record Redis rate sample for %s: %s",
                    self.tenant_id,
                    exc,
                )

    async def _rate_limit_or_raise(self) -> None:
        """Raise if we are outside rate limits."""
        if not await self._check_rate_limit():
            raise SalesforceRateLimitError("Rate limit exceeded", retry_after=60)

    async def _validate_object_name(
        self, sobject: str, *, event_type: str, context: dict
    ) -> str:
        obj = str(sobject or "").strip()
        if not obj or not self._SOBJECT_NAME_RE.match(obj):
            await self._log_security_event(
                event_type, f"Invalid object name: {obj}", context
            )
            raise SecurityViolation("Invalid object name")
        return obj

    async def _validate_record_id(
        self, record_id: str, *, event_type: str, context: dict
    ) -> str:
        rid = str(record_id or "").strip()
        if not rid or not self._SF_ID_RE.match(rid):
            await self._log_security_event(
                event_type, f"Invalid record id: {rid}", context
            )
            raise SecurityViolation("Invalid record id")
        return rid

    async def _validate_object_access(
        self, sobject: str, *, event_type: str, context: dict
    ) -> str:
        obj = await self._validate_object_name(
            sobject, event_type=event_type, context=context
        )
        obj_lower = obj.lower()
        if not settings.SALESFORCE_SECURITY.get("enforce_allowlist", True):
            return obj_lower
        allow_custom_objects = settings.SALESFORCE_SECURITY.get(
            "allow_custom_objects", False
        )
        if obj_lower not in self.ALLOWED_OBJECTS and not allow_custom_objects:
            await self._log_security_event(
                event_type, f"Object not allowed: {obj_lower}", context
            )
            raise SecurityViolation(f"Object not allowed: {obj_lower}")
        return obj_lower

    async def _validate_read_access(self, sobject: str) -> str:
        return await self._validate_object_access(
            sobject,
            event_type="BLOCKED_READ_OBJECT",
            context={"user": None, "ip": None},
        )

    # ========== Security Methods ==========

    async def _validate_write_access(self, sobject: str, payload: Any) -> None:
        """Enforce object/field allowlists for write operations."""
        ctx = {"user": None, "ip": None}
        obj_lower = await self._validate_object_access(
            sobject, event_type="BLOCKED_WRITE_OBJECT", context=ctx
        )
        if not settings.SALESFORCE_SECURITY.get("enforce_allowlist", True):
            return

        allowed_fields = self.ALLOWED_OBJECTS.get(obj_lower)
        if not allowed_fields:
            return

        keys: set[str] = set()
        if isinstance(payload, list):
            for item in payload:
                if isinstance(item, dict):
                    keys |= {str(k).lower() for k in item.keys()}
        elif isinstance(payload, dict):
            keys |= {str(k).lower() for k in payload.keys()}

        if keys:
            allowed_lower = {str(k).lower() for k in allowed_fields}
            if not keys.issubset(allowed_lower):
                blocked_fields = sorted(keys - allowed_lower)
                await self._log_security_event(
                    "BLOCKED_WRITE_FIELDS",
                    ",".join(blocked_fields),
                    ctx,
                )
                raise SecurityViolation("Write fields exceed allowlist")

    async def _validate_composite_payload(self, payload: Any) -> None:
        """Validate composite API requests against object/field allowlists."""
        ctx = {"user": None, "ip": None}
        if not isinstance(payload, dict):
            await self._log_security_event(
                "BLOCKED_COMPOSITE_REQUEST", "Payload must be an object", ctx
            )
            raise SecurityViolation("Composite payload must be a JSON object")

        requests = payload.get("compositeRequest")
        if not isinstance(requests, list):
            await self._log_security_event(
                "BLOCKED_COMPOSITE_REQUEST",
                "Missing compositeRequest list",
                ctx,
            )
            raise SecurityViolation("Composite payload missing compositeRequest")

        for entry in requests:
            if not isinstance(entry, dict):
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    "Composite request entry must be an object",
                    ctx,
                )
                raise SecurityViolation("Composite request entry invalid")

            method = str(entry.get("method") or "").upper().strip()
            url = str(entry.get("url") or "").strip()
            if not method or not url:
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    "Composite request missing method or url",
                    ctx,
                )
                raise SecurityViolation("Composite request missing method or url")

            if not url.startswith("/services/data/"):
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    f"Unsupported composite url: {url}",
                    ctx,
                )
                raise SecurityViolation("Composite request URL not allowed")

            parts = [part for part in url.split("/") if part]
            try:
                idx = parts.index("sobjects")
            except ValueError:
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    f"Composite URL missing sobjects: {url}",
                    ctx,
                )
                raise SecurityViolation("Composite request URL not allowed")

            if idx + 1 >= len(parts):
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    f"Composite URL missing object: {url}",
                    ctx,
                )
                raise SecurityViolation("Composite request URL missing object")

            sobject = parts[idx + 1]

            if method in ("POST", "PATCH", "PUT"):
                body = entry.get("body")
                if not isinstance(body, dict):
                    await self._log_security_event(
                        "BLOCKED_COMPOSITE_REQUEST",
                        "Composite body must be an object",
                        ctx,
                    )
                    raise SecurityViolation("Composite body must be an object")
                await self._validate_write_access(sobject, body)
                if method in ("PATCH", "PUT"):
                    if idx + 2 >= len(parts):
                        await self._log_security_event(
                            "BLOCKED_COMPOSITE_REQUEST",
                            "Composite update missing record id",
                            ctx,
                        )
                        raise SecurityViolation("Composite update missing record id")
                    await self._validate_record_id(
                        parts[idx + 2],
                        event_type="BLOCKED_RECORD_ID",
                        context=ctx,
                    )
            elif method == "DELETE":
                if idx + 2 >= len(parts):
                    await self._log_security_event(
                        "BLOCKED_COMPOSITE_REQUEST",
                        "Composite delete missing record id",
                        ctx,
                    )
                    raise SecurityViolation("Composite delete missing record id")
                await self._validate_record_id(
                    parts[idx + 2],
                    event_type="BLOCKED_RECORD_ID",
                    context=ctx,
                )
                await self._validate_write_access(sobject, {})
            elif method == "GET":
                await self._validate_read_access(sobject)
            else:
                await self._log_security_event(
                    "BLOCKED_COMPOSITE_REQUEST",
                    f"Unsupported composite method: {method}",
                    ctx,
                )
                raise SecurityViolation("Composite method not allowed")

    async def _security_check(self, query: str, context: dict):
        """Security policy enforcement for SOQL queries"""
        # Check if security is enabled
        if not settings.SALESFORCE_SECURITY.get("enforce_allowlist", True):
            return parse_soql_touchpoints(query)

        # Defense in depth: Only allow SELECT queries
        if not re.search(r"^\s*select\b", query, re.IGNORECASE):
            await self._log_security_event(
                "BLOCKED_NON_SELECT", "Non-SELECT query attempted", context, query
            )
            raise SecurityViolation("Only SELECT queries are permitted")

        admin = context.get("admin_override", False)
        allow_no_limit = context.get("allow_no_limit", False)

        # Admin override guardrail (belt-and-suspenders)
        if admin and not context.get("user_is_admin", False):
            await self._log_security_event(
                "ADMIN_OVERRIDE_DENIED", "User lacks admin privileges", context, query
            )
            raise SecurityViolation("Admin override not permitted for this user")

        lower_query = query.lower()

        if not admin and re.search(r"\(\s*select\b", lower_query, re.IGNORECASE):
            await self._log_security_event(
                "BLOCKED_SUBQUERY", "Subquery not permitted", context, query
            )
            raise SecurityViolation("Subqueries are not permitted")

        # Check blocked patterns
        for pat in self.BLOCKED_PATTERNS:
            if re.search(pat, lower_query, re.IGNORECASE) and not admin:
                self._blocked_count += 1
                await self._log_security_event(
                    "BLOCKED_PATTERN", pat[:50], context, query
                )
                raise SecurityViolation("Suspicious SOQL pattern blocked")

        has_count = bool(re.search(r"\bcount(?:_distinct)?\s*\(", lower_query))
        has_limit = bool(re.search(r"\blimit\b", lower_query))

        touched = parse_soql_touchpoints(query)

        raw_allow = context.get("plan_allowlist") or {}
        allow_custom_objects = settings.SALESFORCE_SECURITY.get(
            "allow_custom_objects", False
        )
        plan_allowed_objects: set[str] = set()
        plan_allowed_fields: Dict[str, set[str]] = {}
        if isinstance(raw_allow, dict):
            if "fields" in raw_allow or "objects" in raw_allow:
                plan_allowed_objects = {
                    str(obj).lower() for obj in raw_allow.get("objects", [])
                }
                for key, values in raw_allow.get("fields", {}).items():
                    plan_allowed_fields[str(key).lower()] = {
                        str(v).lower() for v in values or []
                    }
            else:
                for key, values in raw_allow.items():
                    plan_allowed_objects.add(str(key).lower())
                    plan_allowed_fields[str(key).lower()] = {
                        str(v).lower() for v in values or []
                    }

        for obj in touched["objects"]:
            obj_lower = str(obj).lower()
            related_object = safe_relationship_object_for_alias(obj_lower)
            policy_object = str(related_object or obj).lower()
            if admin:
                continue
            allowed_fields = set()
            global_allowed = self.ALLOWED_OBJECTS.get(policy_object)
            if global_allowed is not None:
                allowed_fields |= set(global_allowed)
            plan_fields = plan_allowed_fields.get(policy_object, set())
            if plan_fields:
                allowed_fields |= plan_fields
            object_allowed = (
                policy_object in self.ALLOWED_OBJECTS
                or policy_object in plan_allowed_objects
                or allow_custom_objects
            )
            if not object_allowed:
                self._blocked_count += 1
                await self._log_security_event(
                    "BLOCKED_OBJECT", str(related_object or obj), context, query
                )
                raise SecurityViolation(
                    f"Object not allowed: {str(related_object or obj)}"
                )

            if not has_count and allowed_fields:
                requested = set()
                for field_expr in touched["fields"].get(obj, set()):
                    token = self._field_allowlist_token(
                        str(related_object or obj), field_expr
                    )
                    if token:
                        requested.add(token)
                if not requested.issubset(allowed_fields):
                    blocked_fields = requested - allowed_fields
                    if blocked_fields:
                        self._blocked_count += 1
                        await self._log_security_event(
                            "BLOCKED_FIELDS",
                            ",".join(sorted(blocked_fields)),
                            context,
                            query,
                        )
                        raise SecurityViolation("Requested fields exceed allowlist")

        # Require LIMIT unless it's a COUNT() query or admin override
        if not admin and not has_count and not has_limit and not allow_no_limit:
            await self._log_security_event(
                "BLOCKED_NO_LIMIT", "Missing LIMIT clause", context, query
            )
            raise SecurityViolation("LIMIT clause required for non-COUNT queries")

        # Enforce max query limit from settings
        if has_limit and not admin:
            limit_match = re.search(r"\blimit\s+(\d+)\b", lower_query)
            if limit_match:
                limit_value = int(limit_match.group(1))
                max_limit = settings.SALESFORCE_SECURITY.get("max_query_limit", 1000)
                if limit_value > max_limit:
                    await self._log_security_event(
                        "BLOCKED_LIMIT_TOO_HIGH",
                        f"LIMIT {limit_value} exceeds max {max_limit}",
                        context,
                        query,
                    )
                    raise SecurityViolation(
                        f"LIMIT {limit_value} exceeds maximum allowed ({max_limit})"
                    )

        return touched

    @staticmethod
    def _field_allowlist_token(object_name: str, field_expr: str) -> Optional[str]:
        normalized = str(field_expr or "").strip()
        if not normalized:
            return None

        agg_match = re.fullmatch(
            r"(?i)(?:sum|avg|count|min|max|count_distinct)\s*\(\s*([A-Za-z_][A-Za-z0-9_.]*|\*)\s*\)",
            normalized,
        )
        if agg_match:
            inner = agg_match.group(1).strip()
            if inner == "*":
                return None
            normalized = inner

        if "." in normalized:
            prefix, remainder = normalized.split(".", 1)
            if prefix[:1].isupper() and prefix.lower() != str(object_name or "").lower():
                # Relationship traversal is enforced on its own object touchpoint.
                return None
            leaf = remainder.split(".")[-1].strip().lower()
            return leaf or None

        return normalized.lower()

    def _enforce_allowlist(
        self, allowlist: Dict[str, Any], touchpoints: Dict[str, Dict[str, set]]
    ) -> None:
        """Ensure query only references objects/fields permitted by the plan allowlist.

        NOTE: Relationship leaf fields (e.g. Owner.Name) are intentionally
        skipped here — ``_field_allowlist_token`` returns None for them so the
        gateway trusts the upstream validator (``evaluate_safe_query_scope``)
        which does validate relationship traversals against per-object
        allowlists.  Callers that bypass the validator must enforce
        relationship fields independently.
        """
        if not allowlist:
            return

        if "fields" in allowlist or "objects" in allowlist:
            allowed_objects = {
                str(obj).lower() for obj in allowlist.get("objects", []) if obj
            }
            allowed_fields_map = {
                str(obj).lower(): {str(field).lower() for field in (fields or [])}
                for obj, fields in allowlist.get("fields", {}).items()
            }
        else:
            allowed_objects = {str(obj).lower() for obj in allowlist.keys()}
            allowed_fields_map = {
                str(obj).lower(): {str(field).lower() for field in (fields or [])}
                for obj, fields in allowlist.items()
            }

        for obj in touchpoints.get("objects", set()):
            related_object = safe_relationship_object_for_alias(str(obj).lower())
            policy_object = str(related_object or obj).lower()
            if allowed_objects and policy_object not in allowed_objects:
                raise SalesforceAllowlistError(
                    f"Object {str(related_object or obj)} is not allowed by plan"
                )
            requested = set()
            for field_expr in touchpoints.get("fields", {}).get(obj, set()):
                token = self._field_allowlist_token(
                    str(related_object or obj), field_expr
                )
                if token:
                    requested.add(token)
            allowed_fields = allowed_fields_map.get(policy_object, set())
            if allowed_fields and not requested.issubset(allowed_fields):
                missing = requested - allowed_fields
                raise SalesforceAllowlistError(
                    "Fields not allowed for "
                    + str(related_object or obj)
                    + ": "
                    + ", ".join(sorted(missing))
                )

    async def _find_inaccessible_fields(
        self, touchpoints: Dict[str, Dict[str, set]]
    ) -> List[str]:
        """Return list of fields that are not accessible/queryable for the tenant."""
        issues: List[str] = []
        describe_cache: Dict[str, dict] = {}

        for obj in touchpoints.get("objects", set()):
            object_name = str(obj)
            if object_name not in describe_cache:
                try:
                    describe_cache[object_name] = await self.describe(object_name)
                except Exception as exc:
                    issues.append(f"{object_name} (describe failed: {exc})")
                    continue
            describe = describe_cache[object_name]
            if not describe.get("queryable", True):
                issues.append(f"{object_name} (not queryable)")
                continue
            field_map = {
                (field.get("name") or "").lower(): field
                for field in describe.get("fields", [])
                if field.get("name")
            }
            relationship_targets: Dict[str, List[str]] = {}
            for field_meta in describe.get("fields", []) or []:
                rel_name = str(field_meta.get("relationshipName") or "").strip().lower()
                if not rel_name:
                    continue
                targets = [
                    str(target).strip()
                    for target in field_meta.get("referenceTo", []) or []
                    if str(target).strip()
                ]
                if targets:
                    relationship_targets.setdefault(rel_name, targets)
            for field_expr in touchpoints.get("fields", {}).get(object_name, set()):
                normalized = (field_expr or "").strip()
                lower_norm = normalized.lower()
                if re.match(r"(count(?:_distinct)?|sum|avg|min|max)\s*\(", lower_norm):
                    continue
                lookup_object = object_name
                lookup_field_expr = normalized

                if "." in normalized:
                    prefix, remainder = normalized.split(".", 1)
                    if prefix.lower() == object_name.lower():
                        lookup_field_expr = remainder
                    else:
                        targets = relationship_targets.get(prefix.lower(), [])
                        if len(targets) != 1:
                            # Ambiguous/unresolved relationship path; defer to Salesforce runtime.
                            continue
                        lookup_object = targets[0]
                        lookup_field_expr = remainder

                if lookup_object != object_name:
                    if lookup_object not in describe_cache:
                        try:
                            describe_cache[lookup_object] = await self.describe(lookup_object)
                        except Exception as exc:
                            issues.append(
                                f"{object_name}.{field_expr} (relationship describe failed: {exc})"
                            )
                            continue
                    lookup_describe = describe_cache[lookup_object]
                    if not lookup_describe.get("queryable", True):
                        issues.append(
                            f"{object_name}.{field_expr} ({lookup_object} not queryable)"
                        )
                        continue
                    lookup_field_map = {
                        (field.get("name") or "").lower(): field
                        for field in lookup_describe.get("fields", [])
                        if field.get("name")
                    }
                else:
                    lookup_field_map = field_map

                base_field = lookup_field_expr.split(".")[-1].lower()
                meta = lookup_field_map.get(base_field)
                if not meta:
                    issues.append(f"{object_name}.{field_expr} (no such field)")
                    continue
                if not meta.get("queryable", True) or not meta.get("accessible", True):
                    issues.append(
                        f"{object_name}.{meta.get('name', field_expr)} (no access)"
                    )
        return issues

    def _fire_and_forget(self, coro):
        """Safely run background tasks with exception handling"""

        async def _runner():
            try:
                await coro
            except Exception as e:
                log.error(f"Background security task failed: {e}")

        asyncio.create_task(_runner())

    async def _log_security_event(
        self, event_type: str, details: str, context: dict, query: str = None
    ):
        """Log security events for audit trail with enhanced payload"""
        # Sanitize sensitive data before logging
        sanitized_details = self._sanitize_for_logs(details)
        sanitized_query = self._sanitize_for_logs(query[:1000]) if query else None

        event = {
            "incident_id": str(uuid.uuid4()),
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "tenant": self.tenant_id,
            "user": context.get("user", "unknown"),
            "ip": context.get("ip", "unknown"),
            "event_type": event_type,
            "details": sanitized_details[:500],
            "query_truncated": sanitized_query,
            "admin_override": context.get("admin_override", False),
            "user_is_admin": context.get("user_is_admin", False),
        }
        self._security_events.append(event)

        # Log to system logger
        log.warning(f"SECURITY_EVENT: {json.dumps(event)}")

        # Send to central security logging service (non-blocking)
        if settings.SECURITY_LOG_ENDPOINT:
            self._fire_and_forget(self._send_to_siem(event))

        # Alert on critical events (non-blocking)
        if event_type in [
            "BLOCKED_PATTERN",
            "CREDENTIAL_SCAN",
            "ADMIN_OVERRIDE_DENIED",
            "ADMIN_OVERRIDE_ALLOWED",
        ]:
            self._fire_and_forget(self._alert_security_team(event))

    def _sanitize_for_logs(self, text: str) -> str:
        """Remove secrets from logs"""
        if not text:
            return ""

        # Mask AWS keys
        text = re.sub(r"AKIA[0-9A-Z]{16}", "AKIA[REDACTED]", text)
        # Mask passwords, tokens, secrets
        text = re.sub(
            r"(?:password|token|secret|key|apikey|api_key)[\s:=]+\S+",
            "[REDACTED]",
            text,
            flags=re.I,
        )
        # Mask email passwords
        text = re.sub(r"(?:pwd|pass)[\s:=]+\S+", "[REDACTED]", text, flags=re.I)
        # Mask bearer tokens
        text = re.sub(r"Bearer\s+\S+", "Bearer [REDACTED]", text, flags=re.I)
        # Mask basic auth
        text = re.sub(r"Basic\s+\S+", "Basic [REDACTED]", text, flags=re.I)

        return text[:1000]  # Truncate for safety

    async def _send_to_siem(self, event: dict):
        """Send security event to SIEM/logging service"""
        try:
            endpoint = settings.SECURITY_LOG_ENDPOINT
            if not endpoint:
                return

            async with httpx.AsyncClient() as client:
                await client.post(
                    endpoint,
                    json=event,
                    headers={"X-Source": "salesforce-gateway"},
                    timeout=5.0,
                )
        except Exception as e:
            log.error(f"Failed to send to SIEM: {e}")

    async def _alert_security_team(self, event: dict):
        """Alert security team for critical events"""
        try:
            webhook_url = settings.SECURITY_ALERT_WEBHOOK
            if not webhook_url:
                return

            message = (
                f"🚨 Security Alert - {event['event_type']}\n"
                f"Tenant: {event['tenant']}\n"
                f"User: {event['user']}\n"
                f"IP: {event['ip']}\n"
                f"Incident ID: {event['incident_id']}\n"
                f"Details: {event['details']}"
            )

            async with httpx.AsyncClient() as client:
                await client.post(webhook_url, json={"text": message}, timeout=5.0)
        except Exception as e:
            log.error(f"Failed to alert security team: {e}")

    async def get_security_metrics(self, hours: int = 24) -> dict:
        """Get security metrics for dashboard"""
        cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Filter recent events
        recent_events = [
            e
            for e in self._security_events
            if datetime.fromisoformat(e["timestamp"]) > cutoff
        ]

        # Count by type
        event_types = {}
        admin_overrides = 0
        unique_users = set()
        unique_ips = set()

        for event in recent_events:
            event_type = event["event_type"]
            event_types[event_type] = event_types.get(event_type, 0) + 1

            if event.get("admin_override"):
                admin_overrides += 1

            unique_users.add(event.get("user", "unknown"))
            unique_ips.add(event.get("ip", "unknown"))

        # Get unique objects from query history
        unique_objects = set()
        for entry in self._query_history:
            if datetime.fromisoformat(entry["timestamp"]) > cutoff:
                query_lower = entry["query"].lower()
                # Extract object name from FROM clause
                if "from " in query_lower:
                    parts = query_lower.split("from ")[1].split()
                    if parts:
                        unique_objects.add(parts[0])

        # Count all block types
        blocked_recent = sum(
            event_types.get(t, 0)
            for t in (
                "BLOCKED_PATTERN",
                "BLOCKED_OBJECT",
                "BLOCKED_FIELDS",
                "BLOCKED_NO_LIMIT",
                "BLOCKED_LIMIT_TOO_HIGH",
                "BLOCKED_NON_SELECT",
                "BLOCKED_SUBQUERY",
            )
        )

        return {
            "tenant": self.tenant_id,
            "timeframe_hours": hours,
            "blocked_queries_total": self._blocked_count,
            "blocked_queries_recent": blocked_recent,
            "admin_overrides": admin_overrides,
            "unique_users": len(unique_users),
            "unique_ips": len(unique_ips),
            "unique_objects_queried": list(unique_objects),
            "suspicious_patterns_detected": event_types.get("BLOCKED_PATTERN", 0),
            "credential_scans_blocked": event_types.get("CREDENTIAL_SCAN", 0),
            "event_breakdown": event_types,
            "last_security_sweep": self._last_security_sweep.isoformat()
            if hasattr(self, "_last_security_sweep")
            else None,
            "circuit_breaker_state": self._breaker.state
            if hasattr(self, "_breaker")
            else "unknown",
        }

    async def security_sweep(self) -> dict:
        """Check for exposed credentials in Salesforce data"""
        results = {
            "tenant": self.tenant_id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "blocked_count": self._blocked_count,
            "findings": {},
        }

        # Patterns to check for exposed secrets
        sensitive_patterns = [
            ("AWS_KEY", "AKIA"),
            ("PASSWORD", "password"),
            ("SECRET", "secret"),
            ("TOKEN", "token"),
        ]

        for name, pattern in sensitive_patterns:
            try:
                # Check in Case descriptions (common place for exposed data)
                query = f"SELECT COUNT() FROM Case WHERE Description LIKE '%{pattern}%' LIMIT 1"
                result = await self.soql(query, admin_override=True)
                results["findings"][name] = {
                    "count": result.get("totalSize", 0),
                    "checked": "Case.Description",
                }
            except Exception as e:
                results["findings"][name] = {"error": str(e)[:200]}

        return results

    def get_security_stats(self) -> dict:
        """Get security statistics"""
        return {
            "blocked_queries": self._blocked_count,
            "query_history_size": len(self._query_history),
            "security_events": len(self._security_events),
            "recent_events": list(self._security_events)[-10:],  # Last 10 events
        }

    # === API Compatibility Wrappers ===
    # These methods provide compatibility with code expecting specific method names

    async def get_record(
        self, sobject: str, record_id: str, fields: list[str] | None = None
    ) -> dict:
        """Read a single record (pass-through to client)."""
        await self._validate_read_access(sobject)
        await self._validate_record_id(
            record_id, event_type="BLOCKED_RECORD_ID", context={"user": None, "ip": None}
        )
        return await self._execute("get_record", sobject, record_id, fields or [])

    async def update_record(
        self,
        sobject: str,
        record_id: str,
        data: dict,
        *,
        enforce_lead_guard: bool = True,
    ) -> dict:
        """Update a record (alias) with optional Lead writeback enforcement."""
        await self._validate_record_id(
            record_id, event_type="BLOCKED_RECORD_ID", context={"user": None, "ip": None}
        )
        await self._validate_write_access(sobject, data)
        sobj = (sobject or "").lower()
        if sobj == "lead" and enforce_lead_guard:
            keys = {k.lower() for k in (data or {}).keys()}
            if not keys or len(keys) != 1:
                await self._log_security_event(
                    "BLOCKED_UPDATE_FIELDS",
                    "Multiple-or-empty fields on Lead update",
                    {"user": None, "ip": None},
                )
                raise SecurityViolation("Lead updates must target exactly one field")

            # Determine allowed field from active policy, with env fallback
            allowed: set[str] = set()
            try:
                from ..model_defs.l2a_models import (
                    L2APolicy,
                )  # local import to avoid cycles

                result = await self.db.execute(
                    select(L2APolicy).where(
                        L2APolicy.account_id == self.tenant_id,
                        L2APolicy.status == "ACTIVE",
                    )
                )
                pol = result.scalar_one_or_none()
                if pol and (
                    pol.link_field_api_name or getattr(pol, "lead_lookup_field", None)
                ):
                    allowed.add(
                        (pol.link_field_api_name or pol.lead_lookup_field).lower()
                    )
            except Exception as e:
                log.debug(f"Policy lookup failed for writeback enforcement: {e}")

            if not allowed:
                wb = (
                    (getattr(settings, "L2A_WRITEBACK_FIELD", "") or "").lower().strip()
                )
                if wb:
                    allowed.add(wb)

            if not allowed or not keys.issubset(allowed):
                await self._log_security_event(
                    "BLOCKED_WRITEBACK_FIELDS",
                    f"Attempted={sorted(keys)} Allowed={sorted(allowed)}",
                    {"user": None, "ip": None},
                )
                raise SecurityViolation(
                    "Lead updates restricted to the configured writeback field"
                )

        return await self._execute("update_record", sobject, record_id, data)

    async def bulk_query(
        self,
        soql: str,
        object_type: str = "Account",
        *,
        admin_override: bool = False,
        user: str = None,
        ip: str = None,
        user_is_admin: bool = False,
        plan_allowlist: Optional[Dict[str, Any]] = None,
        fls_guard: bool = True,
        allow_no_limit: bool = True,
    ):
        """Thin wrapper to client's Bulk API query.
        Returns client result directly (list of dicts or raw CSV depending on client).
        """
        ctx = {
            "admin_override": admin_override,
            "user": user,
            "ip": ip,
            "user_is_admin": user_is_admin,
            "plan_allowlist": plan_allowlist or {},
            "allow_no_limit": allow_no_limit,
        }
        touchpoints = await self._security_check(soql, ctx)
        if plan_allowlist:
            self._enforce_allowlist(plan_allowlist, touchpoints)
        if fls_guard:
            invalid = await self._find_inaccessible_fields(touchpoints)
            if invalid:
                raise SalesforceFieldAccessError(invalid_fields=invalid)
        return await self._execute("bulk_query", soql, object_type)

    async def paginate_soql(
        self,
        query: str,
        *,
        admin_override: bool = False,
        user: str = None,
        ip: str = None,
        user_is_admin: bool = False,
        plan_allowlist: Optional[Dict[str, Any]] = None,
        fls_guard: bool = True,
        allow_no_limit: bool = True,
    ):
        """Yield record pages by following nextRecordsUrl."""
        ctx = {
            "admin_override": admin_override,
            "user": user,
            "ip": ip,
            "user_is_admin": user_is_admin,
            "plan_allowlist": plan_allowlist or {},
            "allow_no_limit": allow_no_limit,
        }
        touchpoints = await self._security_check(query, ctx)
        if plan_allowlist:
            self._enforce_allowlist(plan_allowlist, touchpoints)
        if fls_guard:
            invalid = await self._find_inaccessible_fields(touchpoints)
            if invalid:
                raise SalesforceFieldAccessError(invalid_fields=invalid)
        # The client already has a paginate_soql method that handles pagination properly
        # Use it directly - it yields batches of records
        async for batch in self._execute_generator("paginate_soql", query):
            yield batch

    async def get_sample_records(
        self, object_type: str, fields: list[str], n: int = 1000
    ) -> list[dict]:
        """Get a small sample via SOQL SELECT ... LIMIT n for analysis flows."""
        fields_str = ", ".join(fields)
        query = f"SELECT {fields_str} FROM {object_type} LIMIT {n}"
        res = await self.soql(query)
        return res.get("records", []) if isinstance(res, dict) else (res or [])

    async def fetch_next(self, next_url: str) -> dict:
        """Fetch next page by nextRecordsUrl with host/path validation."""
        await self._ensure_integration()
        if not self._client:
            raise SalesforceConnectionError(f"No client available for {self.tenant_id}")
        # Delegate to client; it contains stricter URL validation
        return await self._execute("fetch_next", next_url)

    async def _execute_generator(self, method: str, *args, **kwargs):
        """Execute a client method that returns an async generator."""
        await self._ensure_integration()
        await self._ensure_client()
        if not self._client:
            raise SalesforceConnectionError(f"No client available for {self.tenant_id}")

        client_method = getattr(self._client, method, None)
        if not client_method:
            raise ValueError(f"Method {method} not found on SalesforceClient")

        # Pass through to the async generator
        call_kwargs = self._maybe_attach_db_callback(client_method, dict(kwargs))
        try:
            sig = inspect.signature(client_method)
        except (ValueError, TypeError):
            sig = None
        if sig:
            if (
                "rate_limit_callback" in sig.parameters
                and "rate_limit_callback" not in call_kwargs
            ):
                call_kwargs["rate_limit_callback"] = self._rate_limit_or_raise
            if "track_callback" in sig.parameters and "track_callback" not in call_kwargs:
                call_kwargs["track_callback"] = self._track_api_call
        async for item in client_method(*args, **call_kwargs):
            yield item

    async def describe_object(self, sobject: str) -> dict:
        """Describe an object (alias for describe for compatibility)."""
        return await self.describe(sobject)

    async def list_reports(
        self,
        query: str = "",
        report_type: str = "",
        limit: int = 25,
        page_token: str = "",
    ) -> dict:
        """List Salesforce reports with search/filtering (pass-through to client)."""
        await self._validate_read_access("Report")
        return await self._execute(
            "list_reports", query, report_type, limit, page_token
        )

    async def export_report(self, report_id: str) -> dict:
        """Export a Salesforce report (pass-through to client)."""
        await self._validate_read_access("Report")
        await self._validate_record_id(
            report_id, event_type="BLOCKED_RECORD_ID", context={"user": None, "ip": None}
        )
        return await self._execute("export_report", report_id)


# Custom exceptions
class SalesforceConnectionError(Exception):
    """Base exception for connection issues"""

    pass


class SalesforceNotConfigured(SalesforceConnectionError):
    """No integration exists for tenant"""

    pass


class SalesforceAuthError(SalesforceConnectionError):
    """Authentication failed"""

    pass


class SalesforceRateLimitError(SalesforceConnectionError):
    """Rate limit exceeded"""

    def __init__(self, message, retry_after=None):
        super().__init__(message)
        self.retry_after = retry_after


class SalesforceAllowlistError(SalesforceConnectionError):
    """Raised when a query violates the dynamic plan allowlist."""

    pass


class SalesforceFieldAccessError(SalesforceConnectionError):
    """Raised when a query references fields without FLS access."""

    def __init__(self, invalid_fields: List[str]):
        self.invalid_fields = invalid_fields
        msg = "Restricted Salesforce fields: " + ", ".join(invalid_fields)
        super().__init__(msg)


class SecurityViolation(Exception):
    """Security policy violation"""

    pass


# Augment allowlist with configured writeback field at module load time
SalesforceGateway._augment_allowlist_with_config()


# FastAPI dependency for getting gateway instance
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from ..db import get_session
from ..auth_security import require_account  # <-- Use the one source of truth


async def get_salesforce_gateway(
    db: AsyncSession = Depends(get_session),
    account_id: str = Depends(require_account),  # <-- One source of truth
) -> SalesforceGateway:
    """FastAPI dependency for getting SalesforceGateway instance"""
    return await SalesforceGateway.for_tenant(str(account_id), db)
