"""BackendClient — calls HeyLead Backend API proxy instead of Unipile directly.

Same method signatures as UnipileClient so all 5 MCP tools work unchanged.
The backend handles Unipile auth, account scoping, and API key security.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

import httpx

from ..constants import UNIPILE_POLL_INTERVAL_SECONDS, UNIPILE_POLL_TIMEOUT_SECONDS
from .api_metrics import api_metrics
from .unipile import UnipileAuthError, UnipileError, _is_newsletter_invitation
from .voyager_health import voyager_health

logger = logging.getLogger(__name__)

# User-facing message when backend has no LinkedIn account bound (no new APIs).
_NO_LINKEDIN_MSG = (
    "No LinkedIn account connected on the backend. "
    "Re-run setup_profile to connect LinkedIn, then try again."
)

_TIMEOUT = httpx.Timeout(30.0, connect=30.0, read=30.0, write=60.0)

# Retry configuration for transient failures (send operations only)
_MAX_RETRIES = 3
_RETRY_BACKOFF = [2, 4, 8]  # seconds
_RETRYABLE_STATUS_CODES = {502, 503, 504}


def _raise_rate_limit(resp: httpx.Response) -> None:
    """Raise a clear UnipileError for 429 responses. Optional Retry-After hint."""
    retry_after = (resp.headers.get("Retry-After") or "").strip()
    if retry_after.isdigit():
        secs = int(retry_after)
        if secs >= 60:
            msg = f"Rate limited. Try again in {secs // 60} minutes."
        else:
            msg = f"Rate limited. Try again in {secs} seconds."
    else:
        msg = "Rate limited. Try again in a few minutes."
    raise UnipileError(msg)


def _wrap_connection_error(e: Exception, base_url: str) -> UnipileError:
    """Wrap low-level httpx errors with user-friendly messages."""
    if isinstance(e, httpx.ConnectError):
        return UnipileError(
            f"Cannot connect to HeyLead backend at {base_url}. "
            "Is the URL correct? Check your internet connection."
        )
    if isinstance(e, httpx.ConnectTimeout):
        return UnipileError(
            f"Connection to {base_url} timed out. "
            "The backend may be starting up — try again in 30 seconds."
        )
    if isinstance(e, httpx.ReadTimeout):
        return UnipileError(
            f"Request to {base_url} timed out waiting for response. "
            "Try again — the backend may be under heavy load."
        )
    if isinstance(e, httpx.TimeoutException):
        return UnipileError(f"Request to {base_url} timed out. Try again.")
    return UnipileError(f"Backend request failed: {e}")


def _classify_nested_response(
    data: dict[str, Any],
    result: dict[str, Any],
    *,
    accept_codes: tuple[int, ...] = (200, 201),
) -> None:
    """Parse nested Unipile status_code from backend proxy response.

    The backend proxy returns ``{"status_code": N, "body": ...}`` for send
    operations. This helper classifies the nested code and mutates *result*
    in-place with the appropriate flags (success/error/blocked/auth_error/permanent).
    """
    status_code = data.get("status_code")
    if status_code is None:
        logger.warning("Backend response missing status_code field: %s", data)
        result["error"] = "Backend response missing status_code"
        return
    if status_code in accept_codes:
        result["success"] = True
        # Extract external IDs from the nested Unipile response body
        body = data.get("body")
        if isinstance(body, dict):
            for key in ("id", "message_id", "invitation_id", "comment_id",
                        "reaction_id", "chat_id"):
                val = body.get(key)
                if val:
                    result[key] = str(val)
        return

    resp_body = data.get("body") or {}
    resp_text = str(resp_body).lower()

    if status_code == 429:
        result["error"] = "Rate limited by LinkedIn. Will retry later."
        result["blocked"] = True
    elif status_code in (401, 403):
        detail = str(resp_body)[:300] if resp_body else "no body"
        # Distinguish subscription/permission errors from real auth failures
        if "subscription_required" in resp_text or "subscription required" in resp_text:
            result["error"] = f"LinkedIn Premium required for this action. Unipile {status_code}: {detail}"
            result["permanent"] = True
        elif "not connected" in resp_text or "not_connected" in resp_text:
            result["error"] = f"Cannot message — not a connection. Unipile {status_code}: {detail}"
            result["permanent"] = True
        elif status_code == 401:
            logger.error(
                "Auth error from Unipile: status=%d body=%s", status_code, detail,
            )
            result["error"] = f"LinkedIn account disconnected. Unipile {status_code}: {detail}"
            result["auth_error"] = True
            result["blocked"] = True
        else:
            # Generic 403 — could be permission, not necessarily disconnected
            logger.warning(
                "Permission error from Unipile: status=%d body=%s", status_code, detail,
            )
            result["error"] = f"Permission denied by LinkedIn. Unipile {status_code}: {detail}"
            result["permanent"] = True
    elif status_code == 409:
        result["error"] = "Already connected or action already taken."
    elif status_code == 404:
        result["error"] = f"Not found: {str(resp_body)[:200]}"
        result["permanent"] = True
    elif status_code == 422:
        if "temporary provider limit" in resp_text or "temporary_provider_limit" in resp_text:
            result["error"] = "Unipile returned 422: temporary_provider_limit"
            result["blocked"] = True
            result["rate_limited_422"] = True
        elif "cannot_resend_yet" in resp_text or "cannot resend yet" in resp_text:
            result["success"] = True
        elif "already_invited_recently" in resp_text or "already invited" in resp_text:
            # Invite was already sent — treat as success so outreach is marked "invited"
            result["success"] = True
        else:
            result["error"] = f"Unipile returned 422: {str(resp_body)[:200]}"
            result["permanent"] = True
    else:
        detail = str(resp_body)[:300] if resp_body else "no body"
        result["error"] = f"Unipile returned {status_code}: {detail}"


def _parse_timestamp(ts_raw: Any) -> int:
    """Parse a timestamp from various formats into unix seconds."""
    if isinstance(ts_raw, (int, float)):
        return int(ts_raw)
    if isinstance(ts_raw, str) and ts_raw:
        try:
            from datetime import datetime
            return int(datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).timestamp())
        except (ValueError, TypeError):
            pass
    return 0


class BackendClient:
    """Async HTTP client that calls HeyLead Backend API proxy.

    Provides the same interface as UnipileClient so tools don't need to know
    whether they're talking to Unipile directly or via the backend.
    """

    def __init__(self, backend_url: str, jwt_token: str) -> None:
        self.base_url = backend_url.rstrip("/")
        self.jwt_token = jwt_token
        self._client = httpx.AsyncClient(timeout=_TIMEOUT)

    def _headers(self) -> dict[str, str]:
        from ..correlation import get_correlation_id

        headers = {
            "Authorization": f"Bearer {self.jwt_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }
        cid = get_correlation_id()
        if cid:
            headers["X-Correlation-ID"] = cid
        return headers

    async def close(self) -> None:
        """No-op: BackendClient is a singleton; the httpx client stays alive.

        Multiple tools call client.close() after use. With a singleton this
        would break subsequent requests ("client has been closed"). Mirroring
        the _UnclosableConnection pattern used for SQLite.
        """
        pass

    async def _real_close(self) -> None:
        """Actually close the underlying httpx client (for shutdown only)."""
        await self._client.aclose()

    # ── Voyager Health Check ──

    _voyager_available: bool | None = None
    _voyager_checked_at: float = 0.0

    async def is_voyager_available(self, endpoint: str = "wvmpCards") -> bool:
        """Check if Voyager magic route is working (cached for 1 hour).

        Tests the voyager-test endpoint with a known wvmpCards URL.
        Also feeds the per-endpoint ``voyager_health`` tracker.

        Args:
            endpoint: Optional endpoint name for per-endpoint health queries.
                      Pass a specific name (e.g. "follow", "ssi") to check
                      that endpoint's health in the tracker; defaults to the
                      general "wvmpCards" probe.

        Returns True if Voyager responds with 200, False otherwise.
        """
        import time as _time

        # Fast path: if the per-endpoint tracker says this endpoint is unhealthy,
        # skip the network call entirely.
        if not voyager_health.is_healthy(endpoint):
            return False

        now = _time.time()
        # Cache the wvmpCards probe for 1 hour
        if self._voyager_available is not None and (now - self._voyager_checked_at) < 3600:
            return self._voyager_available

        # Use wvmpCards (Who Viewed My Profile) as the health check URL.
        # This is the most reliable Voyager endpoint — other routes like SSI,
        # company feed, and followers return 400 on some LinkedIn sessions.
        test_url = "https://www.linkedin.com/voyager/api/identity/wvmpCards"
        try:
            url = f"{self.base_url}/api/v1/linkedin/voyager-test"
            resp = await self._client.post(
                url,
                json={"url": test_url, "method": "GET"},
                headers=self._headers(),
            )
            if resp.status_code == 200:
                data = resp.json()
                # Voyager working if inner status is 200
                inner_status = data.get("status_code", 0)
                inner_body = str(data.get("body", ""))[:200]
                self._voyager_available = inner_status == 200
                voyager_health.record("wvmpCards", success=self._voyager_available)
                logger.info(
                    "Voyager health check: outer=200 inner_status=%d available=%s body=%s",
                    inner_status, self._voyager_available, inner_body,
                )
            else:
                self._voyager_available = False
                voyager_health.record("wvmpCards", success=False)
                logger.warning(
                    "Voyager health check: outer_status=%d body=%s",
                    resp.status_code, str(resp.text)[:300],
                )
        except Exception as e:
            self._voyager_available = False
            voyager_health.record("wvmpCards", success=False)
            logger.warning("Voyager health check exception: %s", e)

        self._voyager_checked_at = now
        return self._voyager_available

    async def _retry_request(
        self,
        method: str,
        url: str,
        *,
        json: dict | None = None,
        params: dict | None = None,
    ) -> httpx.Response:
        """HTTP request with retry on transient failures.

        Used for send operations (invitation, message, comment, reaction, posts).
        Setup/auth operations should NOT use this — they fail fast.
        """
        import time as _time

        # Derive a short endpoint label from the URL path for metrics
        endpoint_label = url.rsplit("/", 1)[-1] if "/" in url else url

        last_error: Exception | None = None
        for attempt in range(_MAX_RETRIES):
            t0 = _time.monotonic()
            try:
                if method.upper() == "GET":
                    resp = await self._client.get(url, headers=self._headers(), params=params)
                else:
                    resp = await self._client.post(url, json=json, headers=self._headers())
                elapsed = int((_time.monotonic() - t0) * 1000)
                api_metrics.record(
                    endpoint_label,
                    status_code=resp.status_code,
                    duration_ms=elapsed,
                    error=resp.text[:200] if resp.status_code >= 400 else "",
                )
                if resp.status_code == 429:
                    if attempt < _MAX_RETRIES - 1:
                        # Parse Retry-After header if present, else use backoff
                        retry_after = resp.headers.get("retry-after")
                        if retry_after and retry_after.isdigit():
                            delay = min(int(retry_after), 60)
                        else:
                            delay = _RETRY_BACKOFF[attempt] * 2  # double backoff for rate limits
                        logger.info(
                            "Rate limited (429), retrying %s %s in %ds (attempt %d)",
                            method, url, delay, attempt + 1,
                        )
                        await asyncio.sleep(delay)
                        continue
                    _raise_rate_limit(resp)
                if resp.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES - 1:
                    delay = _RETRY_BACKOFF[attempt]
                    logger.info(
                        f"Retry {method} {url} (HTTP {resp.status_code}, "
                        f"attempt {attempt + 1}, {delay}s)"
                    )
                    await asyncio.sleep(delay)
                    continue
                return resp
            except (httpx.TimeoutException, httpx.ConnectError) as e:
                elapsed = int((_time.monotonic() - t0) * 1000)
                api_metrics.record(
                    endpoint_label, status_code=0, duration_ms=elapsed, error=str(e),
                )
                last_error = e
                if attempt < _MAX_RETRIES - 1:
                    delay = _RETRY_BACKOFF[attempt]
                    logger.info(
                        f"Retry {method} {url} ({type(e).__name__}, "
                        f"attempt {attempt + 1}, {delay}s)"
                    )
                    await asyncio.sleep(delay)
                else:
                    raise
        raise last_error or httpx.TimeoutException("All retries exhausted")

    def _check_rate_limit(self, resp: httpx.Response) -> None:
        """Raise a clear error for 429 so direct request paths surface rate-limit message."""
        if resp.status_code == 429:
            _raise_rate_limit(resp)

    # ── Auth / Account Management ──

    async def create_hosted_auth_link(self, success_redirect_url: str = "") -> str:
        """Create a LinkedIn auth link via the backend proxy."""
        url = f"{self.base_url}/api/v1/auth/connect"
        params = {}
        if success_redirect_url:
            params["success_redirect_url"] = success_redirect_url
        try:
            resp = await self._client.post(url, params=params, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        self._check_rate_limit(resp)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid. Run setup_profile again.")
        if resp.status_code == 409:
            data = resp.json()
            raise UnipileError(data.get("detail", "Account already connected"))
        resp.raise_for_status()
        result = resp.json()
        auth_url = result.get("url")
        if not auth_url:
            raise UnipileError(f"Backend response missing 'url': {result}")
        return auth_url

    async def list_accounts(self) -> list[dict[str, Any]]:
        """List accounts via the backend proxy (filtered to user's account)."""
        url = f"{self.base_url}/api/v1/accounts"
        try:
            resp = await self._client.get(url, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        self._check_rate_limit(resp)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        resp.raise_for_status()
        data = resp.json()
        return data.get("accounts", [])

    async def find_linkedin_account(self) -> tuple[str | None, str]:
        """Find a LinkedIn account from the backend."""
        try:
            accounts = await self.list_accounts()
        except Exception as e:
            return None, f"Failed to list accounts: {e}"

        if not accounts:
            return None, "No accounts found."

        for acc in accounts:
            provider = acc.get("provider") or acc.get("provider_type") or acc.get("type") or ""
            if "LINKEDIN" in str(provider).upper():
                account_id = (
                    acc.get("id") or acc.get("account_id")
                    or acc.get("accountId") or acc.get("uuid")
                )
                if account_id:
                    return str(account_id), "Found LinkedIn account"
        return None, "No LinkedIn accounts found."

    async def get_user_info(self) -> dict[str, Any]:
        """Get the authenticated user's account bindings from the backend.

        Returns dict with account_id, email_account_id, email, name.
        """
        url = f"{self.base_url}/api/v1/auth/me"
        try:
            resp = await self._client.get(url, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        self._check_rate_limit(resp)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        resp.raise_for_status()
        return resp.json()

    async def verify_account(self, account_id: str) -> tuple[bool, str]:
        """Check if a Unipile account is still connected via the backend."""
        try:
            url = f"{self.base_url}/api/v1/accounts/{account_id}"
            resp = await self._client.get(url, headers=self._headers())
            self._check_rate_limit(resp)
            if resp.status_code == 403:
                return False, "Access denied: not your account"
            if resp.status_code == 404:
                return False, "Account not found"
            if resp.status_code == 401:
                return False, "Backend JWT expired"
            resp.raise_for_status()
            data = resp.json()
            status = data.get("status") or data.get("state") or data.get("connection_status") or ""
            if isinstance(status, str) and status.lower() in ("disconnected", "error", "failed", "expired"):
                return False, f"Account status: {status}"
            return True, "Connected"
        except Exception as e:
            return False, f"Verification error: {e}"

    async def bind_account(self, account_id: str) -> None:
        """Bind a Unipile account_id to the authenticated user on the backend."""
        url = f"{self.base_url}/api/v1/accounts/{account_id}/bind"
        resp = await self._client.post(url, headers=self._headers())
        self._check_rate_limit(resp)
        if resp.status_code == 409:
            pass  # Already bound — that's fine
        elif resp.status_code != 200:
            detail = resp.text[:200]
            raise UnipileError(f"Failed to bind account: {resp.status_code} — {detail}")

    async def unlink_account(self) -> None:
        """Remove the LinkedIn account binding for the authenticated user on the backend.

        Backend clears the user→account mapping. Does not delete the Unipile account.
        Caller should clear local account_id (e.g. unipile_account_id) after this.
        """
        url = f"{self.base_url}/api/v1/accounts/unlink"
        resp = await self._client.delete(url, headers=self._headers())
        self._check_rate_limit(resp)
        if resp.status_code == 400:
            # No account to unlink — treat as success (idempotent)
            return
        if resp.status_code not in (200, 204):
            detail = resp.text[:200]
            raise UnipileError(f"Failed to unlink account: {resp.status_code} — {detail}")

    async def get_existing_account_ids(self) -> set[str]:
        """Snapshot all current account IDs via the backend."""
        try:
            accounts = await self.list_accounts()
        except Exception:
            return set()
        ids: set[str] = set()
        for acc in accounts:
            aid = acc.get("id") or acc.get("account_id") or acc.get("accountId") or acc.get("uuid")
            if aid:
                ids.add(str(aid))
        return ids

    async def poll_for_new_account(
        self,
        known_ids: set[str],
        timeout_seconds: int = UNIPILE_POLL_TIMEOUT_SECONDS,
        interval: int = UNIPILE_POLL_INTERVAL_SECONDS,
    ) -> tuple[str | None, str]:
        """Poll for a NEW LinkedIn account that wasn't in known_ids."""
        elapsed = 0
        while elapsed < timeout_seconds:
            try:
                accounts = await self.list_accounts()
            except Exception:
                await asyncio.sleep(interval)
                elapsed += interval
                continue

            for acc in accounts:
                aid = acc.get("id") or acc.get("account_id") or acc.get("accountId") or acc.get("uuid")
                if not aid or str(aid) in known_ids:
                    continue
                provider = acc.get("provider") or acc.get("provider_type") or acc.get("type") or ""
                if "LINKEDIN" not in str(provider).upper():
                    continue
                account_id = str(aid)
                connected, status = await self.verify_account(account_id)
                if connected:
                    # Bind this account to the user on the backend
                    await self.bind_account(account_id)
                    return account_id, "LinkedIn account connected!"

            await asyncio.sleep(interval)
            elapsed += interval

        return None, f"Timed out after {timeout_seconds}s waiting for LinkedIn connection."

    # ── Profile ──

    async def get_own_profile(self, account_id: str) -> dict[str, Any]:
        """Fetch the user's LinkedIn profile via the backend proxy.

        The backend returns raw Unipile data; we normalize here (same as UnipileClient).
        """
        url = f"{self.base_url}/api/v1/profile"
        try:
            resp = await self._client.get(url, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code in (401, 403):
            raise UnipileAuthError()
        if resp.status_code == 400:
            raise UnipileError(_NO_LINKEDIN_MSG)
        resp.raise_for_status()
        data = resp.json()
        if isinstance(data, list) and data:
            data = data[0]

        # Normalize — same logic as UnipileClient.get_own_profile
        first_name = data.get("first_name") or data.get("firstName") or ""
        last_name = data.get("last_name") or data.get("lastName") or ""
        headline = data.get("headline") or data.get("occupation") or ""
        public_id = data.get("public_identifier") or data.get("publicIdentifier") or ""
        provider_id = data.get("provider_id") or data.get("id") or ""

        # Use direct URL from Unipile if available, else construct from public_id
        profile_url = (
            data.get("profile_url")
            or data.get("public_profile_url")
            or data.get("url")
            or ""
        )
        if not profile_url and public_id:
            profile_url = f"https://www.linkedin.com/in/{public_id}"

        title = headline
        company = ""
        if " at " in headline:
            parts = headline.rsplit(" at ", 1)
            title = parts[0]
            company = parts[1]

        experience = data.get("experience") or data.get("positions") or []
        if isinstance(experience, list) and experience:
            current = experience[0]
            if isinstance(current, dict):
                title = current.get("title") or current.get("role") or title
                company = current.get("company") or current.get("company_name") or company

        location = data.get("location") or ""
        if isinstance(location, dict):
            location = location.get("name") or location.get("default") or str(location)

        skills_raw = data.get("skills") or []
        skills: list[str] = []
        if isinstance(skills_raw, list):
            for s in skills_raw:
                if isinstance(s, str):
                    skills.append(s)
                elif isinstance(s, dict):
                    skills.append(s.get("name") or s.get("skill") or str(s))

        # Profile picture URL (for photo backup before replacement)
        profile_picture_url = (
            data.get("profile_picture_url")
            or data.get("picture_url")
            or data.get("displayPictureUrl")
            or ""
        )

        return {
            "name": f"{first_name} {last_name}".strip(),
            "first_name": first_name,
            "last_name": last_name,
            "headline": headline,
            "title": title,
            "company": company,
            "location": location,
            "summary": data.get("summary") or data.get("about") or "",
            "industry": data.get("industry") or "",
            "public_id": public_id,
            "provider_id": str(provider_id),
            "profile_url": profile_url,
            "profile_picture_url": profile_picture_url,
            "connections": data.get("connections_count") or data.get("network_info", {}).get("connections_count", 0),
            "skills": skills,
            "experience": experience if isinstance(experience, list) else [],
            "posts": [],
        }

    async def get_posts(
        self, account_id: str, provider_id: str = "", limit: int = 10,
    ) -> list[dict[str, str]]:
        """Fetch recent LinkedIn posts via the backend proxy."""
        params = {"limit": str(limit)}
        if provider_id:
            params["provider_id"] = provider_id

        url = f"{self.base_url}/api/v1/posts"
        try:
            resp = await self._client.get(url, params=params, headers=self._headers())
            if resp.status_code in (404, 400):
                return []
            resp.raise_for_status()
            data = resp.json()

            items = []
            if isinstance(data, list):
                items = data
            elif isinstance(data, dict):
                items = data.get("items") or data.get("data") or data.get("posts") or []

            posts: list[dict[str, str]] = []
            for item in items[:limit]:
                text = ""
                if isinstance(item, dict):
                    text = item.get("text") or item.get("body") or item.get("content") or ""
                elif isinstance(item, str):
                    text = item
                if text:
                    urn = ""
                    if isinstance(item, dict):
                        urn = item.get("id") or item.get("urn") or item.get("social_id") or ""
                    posts.append({"text": str(text), "urn": str(urn)})
            return posts
        except Exception as e:
            logger.warning(f"Failed to fetch posts via backend: {e}")
            return []

    # ── Search ──

    async def search_people(
        self,
        account_id: str,
        keywords: str = "",
        title: str = "",
        location: str = "",
        count: int = 25,
        *,
        industry_codes: list[str] | None = None,
        location_codes: list[str] | None = None,
        title_keywords: list[str] | None = None,
        seniority: list[str] | None = None,
        company_headcount: dict[str, int] | None = None,
        company_types: list[str] | None = None,
        department_codes: list[str] | None = None,
        tenure: dict[str, int] | None = None,
        use_sales_navigator: bool = False,
        role_codes: list[str] | None = None,
        cursor: str | None = None,
        spotlight: dict[str, bool] | None = None,
        annual_revenue: dict[str, int] | None = None,
        company_headcount_growth: str | None = None,
        search_account_id: str | None = None,
        network_distance: list[int] | None = None,
    ) -> tuple[list[dict[str, Any]], str | None]:
        """Search LinkedIn for people via the backend proxy with structured filters.

        Args:
            search_account_id: Optional override to route search through a different
                account (e.g., a premium Sales Navigator account). Must be the user's
                own account or an active pool account on the backend.
        """
        search_query = keywords
        if title and title.lower() not in keywords.lower():
            search_query = f"{title} {keywords}"

        url = f"{self.base_url}/api/v1/search"
        payload: dict[str, Any] = {
            "category": "people",
            "limit": min(count, 100 if use_sales_navigator else 50),
            "keywords": search_query or "",  # Backend requires keywords field
        }

        if use_sales_navigator:
            payload["api"] = "sales_navigator"
            if industry_codes:
                payload["industry"] = {"include": industry_codes}
            if location_codes:
                payload["location"] = {"include": location_codes}
            if role_codes:
                payload["role"] = {"include": role_codes}
            if seniority:
                payload["seniority"] = {"include": seniority}
            if company_headcount:
                payload["company_headcount"] = [company_headcount]
            if company_types:
                payload["company_type"] = company_types
            if department_codes:
                payload["function"] = {"include": department_codes}
            if tenure:
                payload["tenure"] = [tenure]
            if spotlight:
                payload["spotlight"] = spotlight
            if annual_revenue:
                payload["annual_revenue"] = annual_revenue
            if company_headcount_growth:
                payload["company_headcount_growth"] = company_headcount_growth
        else:
            payload["api"] = "classic"
            if industry_codes:
                payload["industry"] = industry_codes
            if location_codes:
                payload["location"] = location_codes
            if title_keywords:
                payload["advanced_keywords"] = {
                    "title": " OR ".join(title_keywords),
                }

        if cursor:
            payload["cursor"] = cursor
        if network_distance:
            payload["network_distance"] = network_distance

        # Route through a different account for premium search
        if search_account_id:
            payload["search_account_id"] = search_account_id

        try:
            try:
                resp = await self._client.post(url, json=payload, headers=self._headers())
            except (httpx.ConnectError, httpx.TimeoutException) as e:
                raise _wrap_connection_error(e, self.base_url)
            self._check_rate_limit(resp)
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code == 400:
                raise UnipileError(_NO_LINKEDIN_MSG)
            if resp.status_code >= 400:
                try:
                    err_body = resp.json()
                    detail = err_body.get("detail", err_body.get("message", resp.text[:200]))
                except Exception:
                    detail = resp.text[:200]
                raise UnipileError(f"Backend search failed ({resp.status_code}): {detail}")
            data = resp.json()

            items = data.get("items") or data.get("results") or data.get("data") or []
            if isinstance(data, list):
                items = data

            next_cursor = None
            if isinstance(data, dict):
                next_cursor = data.get("cursor") or data.get("next_cursor") or data.get("paging", {}).get("cursor")

            results: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                name_parts = []
                if item.get("first_name") or item.get("firstName"):
                    name_parts.append(item.get("first_name") or item.get("firstName") or "")
                if item.get("last_name") or item.get("lastName"):
                    name_parts.append(item.get("last_name") or item.get("lastName") or "")
                name = " ".join(name_parts).strip() or item.get("name") or item.get("full_name") or item.get("display_name") or ""
                if not name and isinstance(item.get("profile"), dict):
                    prof = item.get("profile") or {}
                    name = prof.get("name") or " ".join(filter(None, [prof.get("first_name"), prof.get("last_name")])).strip()
                if not name:
                    continue

                headline_val = item.get("headline") or item.get("headline_text") or ""
                pub_id = item.get("public_identifier") or item.get("publicIdentifier") or item.get("public_id") or ""
                prov_id = item.get("provider_id") or item.get("id") or item.get("member_urn") or item.get("urn") or ""

                parsed_title = headline_val
                parsed_company = ""
                if " at " in headline_val:
                    parts = headline_val.rsplit(" at ", 1)
                    parsed_title = parts[0]
                    parsed_company = parts[1]

                loc = item.get("location") or ""
                if isinstance(loc, dict):
                    loc = loc.get("name") or loc.get("default") or loc.get("display_name") or ""

                profile_url = item.get("profile_url") or item.get("public_profile_url") or item.get("url") or ""
                if not profile_url and pub_id:
                    profile_url = f"https://www.linkedin.com/in/{pub_id}"

                results.append({
                    "name": name,
                    "title": parsed_title,
                    "company": parsed_company,
                    "headline": headline_val,
                    "location": str(loc),
                    "linkedin_url": profile_url,
                    "public_id": pub_id,
                    "provider_id": str(prov_id),
                })
            if items and not results:
                logger.warning(
                    "Backend returned %s items but none could be parsed. Sample keys: %s",
                    len(items),
                    list(items[0].keys()) if items else [],
                )
                raise UnipileError(
                    "Backend search returned results in an unexpected format. "
                    "Check heylead-api and Unipile response shape (expected first_name/last_name or name)."
                )
            return results, next_cursor
        except UnipileAuthError:
            raise
        except UnipileError:
            raise
        except Exception as e:
            logger.warning(f"Backend search error: {e}", exc_info=True)
            raise UnipileError(f"LinkedIn search failed: {e}") from e

    async def detect_sales_navigator(self, account_id: str) -> bool:
        """Check if the LinkedIn account has Sales Navigator via backend."""
        url = f"{self.base_url}/api/v1/check-sales-nav"
        params: dict[str, str] = {}
        if account_id:
            params["account_id"] = account_id
        try:
            resp = await self._client.get(url, params=params, headers=self._headers())
            if resp.status_code != 200:
                return False
            data = resp.json()
            return data.get("has_sales_navigator", False)
        except Exception:
            return False

    async def check_sales_nav_all(self) -> list[dict[str, Any]]:
        """Check Sales Navigator status for all accessible accounts (own + pool).

        Returns list of {account_id, name, has_sales_navigator} dicts.
        """
        url = f"{self.base_url}/api/v1/check-sales-nav-all"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code != 200:
                return []
            data = resp.json()
            return data.get("accounts", [])
        except Exception:
            return []

    async def get_profile(
        self,
        account_id: str,
        identifier: str,
        use_sales_navigator: bool = False,
    ) -> dict[str, Any]:
        """Fetch full LinkedIn profile via backend proxy."""
        url = f"{self.base_url}/api/v1/linkedin/users/{identifier}"
        params: dict[str, str] = {}
        if use_sales_navigator:
            params["linkedin_api"] = "sales_navigator"
        try:
            resp = await self._retry_request("GET", url, params=params)
            if resp.status_code in (404, 400):
                return {}
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            resp.raise_for_status()
            return resp.json()
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Profile fetch error: {e}")
            return {}

    async def check_existing_relation(
        self,
        account_id: str,
        provider_id: str,
    ) -> dict[str, Any]:
        """Check existing relation with a prospect via backend."""
        result = {"connected": False, "pending_invite": False, "has_chat": False, "chat_id": ""}
        try:
            chat_id = await self.find_chat_for_user(account_id, provider_id)
            if chat_id:
                result["has_chat"] = True
                result["chat_id"] = chat_id
                result["connected"] = True
        except Exception as e:
            logger.debug(f"Relation check skipped: {e}")
        return result

    # ── Messaging ──

    async def send_invitation(
        self,
        account_id: str,
        provider_id: str,
        message: str = "",
    ) -> dict[str, Any]:
        """Send a LinkedIn connection invitation via the backend proxy."""
        result: dict[str, Any] = {"success": False, "error": "", "blocked": False, "auth_error": False}

        url = f"{self.base_url}/api/v1/invite"
        payload: dict[str, Any] = {"provider_id": provider_id}
        if message:
            payload["message"] = message[:200]

        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                result["auth_error"] = True
                result["blocked"] = True
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                result["blocked"] = True
                return result

            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)

        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
            result["blocked"] = True
        except Exception as e:
            result["error"] = f"Send failed: {e}"

        return result

    async def get_chats(
        self,
        account_id: str,
        limit: int = 20,
    ) -> list[dict[str, Any]]:
        """Fetch recent LinkedIn messages via the backend proxy.

        The Unipile GET /api/v1/chats endpoint returns chat metadata without
        embedded messages. For chats missing message content, we fetch the
        last message via GET /api/v1/chats/{id}/messages in parallel.
        """
        url = f"{self.base_url}/api/v1/chats"
        params = {"limit": str(limit)}

        try:
            try:
                resp = await self._retry_request("GET", url, params=params)
            except (httpx.ConnectError, httpx.TimeoutException) as e:
                raise _wrap_connection_error(e, self.base_url)
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code == 400:
                raise UnipileError("No LinkedIn account connected.")
            resp.raise_for_status()
            data = resp.json()

            items = data.get("items") or data.get("chats") or data.get("data") or []
            if isinstance(data, list):
                items = data

            # Normalize chat items — extract embedded messages or fetch them
            messages: list[dict[str, Any]] = []
            needs_fetch: list[dict] = []  # chats missing message content

            for chat in items:
                if not isinstance(chat, dict):
                    continue
                chat_id = chat.get("id") or chat.get("chat_id") or ""
                if not chat_id:
                    continue

                chat_messages = chat.get("messages") or chat.get("last_messages") or []
                if isinstance(chat_messages, list) and chat_messages:
                    last_msg = chat_messages[-1] if isinstance(chat_messages[-1], dict) else {}
                elif isinstance(chat_messages, dict):
                    last_msg = chat_messages
                else:
                    last_msg = {}

                text = last_msg.get("text") or last_msg.get("body") or last_msg.get("content") or ""
                if not text:
                    # No embedded message — need to fetch separately
                    needs_fetch.append(chat)
                    continue

                sender_id = last_msg.get("sender_id") or last_msg.get("sender", {}).get("provider_id", "")
                sender_name = last_msg.get("sender_name") or last_msg.get("sender", {}).get("display_name", "")

                attendees = chat.get("attendees") or []
                if not sender_name:
                    for att in attendees:
                        if isinstance(att, dict):
                            att_id = att.get("provider_id") or att.get("id") or ""
                            if att_id and att_id == sender_id:
                                sender_name = att.get("display_name") or att.get("name") or ""
                                break
                            elif not sender_name:
                                sender_name = att.get("display_name") or att.get("name") or ""

                # Extract attendee provider_ids for reply matching
                attendee_ids = []
                for att in attendees:
                    if isinstance(att, dict):
                        att_id = att.get("provider_id") or att.get("id") or ""
                        if att_id:
                            attendee_ids.append(str(att_id))
                # Fallback: Unipile returns attendee_provider_id as a flat field
                att_pid = chat.get("attendee_provider_id") or ""
                if att_pid and str(att_pid) not in attendee_ids:
                    attendee_ids.append(str(att_pid))

                ts_raw = last_msg.get("timestamp") or last_msg.get("created_at") or last_msg.get("date") or ""
                timestamp = _parse_timestamp(ts_raw)

                messages.append({
                    "sender_name": sender_name,
                    "sender_id": str(sender_id),
                    "text": text,
                    "timestamp": timestamp,
                    "conversation_urn": str(chat_id),
                    "attendee_ids": attendee_ids,
                })

            # Fetch last message for chats that didn't embed it (batch of 5)
            if needs_fetch:
                import asyncio as _aio
                BATCH = 5
                for start in range(0, len(needs_fetch), BATCH):
                    batch = needs_fetch[start:start + BATCH]
                    tasks = [
                        self.get_chat_messages(account_id, c.get("id") or c.get("chat_id") or "", limit=1)
                        for c in batch
                    ]
                    results = await _aio.gather(*tasks, return_exceptions=True)
                    for chat, result in zip(batch, results):
                        if isinstance(result, Exception) or not result:
                            continue
                        last_msg = result[0] if isinstance(result, list) and result else {}
                        if not isinstance(last_msg, dict):
                            continue
                        text = last_msg.get("text") or ""
                        if not text:
                            continue

                        chat_id = chat.get("id") or chat.get("chat_id") or ""
                        sender_id = last_msg.get("sender_id") or ""
                        sender_name = last_msg.get("sender_name") or ""

                        # Fall back to attendees for sender name
                        attendees = chat.get("attendees") or []
                        if not sender_name:
                            for att in attendees:
                                if isinstance(att, dict):
                                    att_id = att.get("provider_id") or att.get("id") or ""
                                    if att_id and att_id == sender_id:
                                        sender_name = att.get("display_name") or att.get("name") or ""
                                        break
                                    elif not sender_name:
                                        sender_name = att.get("display_name") or att.get("name") or ""

                        attendee_ids = []
                        for att in attendees:
                            if isinstance(att, dict):
                                att_id = att.get("provider_id") or att.get("id") or ""
                                if att_id:
                                    attendee_ids.append(str(att_id))
                        att_pid = chat.get("attendee_provider_id") or ""
                        if att_pid and str(att_pid) not in attendee_ids:
                            attendee_ids.append(str(att_pid))

                        timestamp = last_msg.get("timestamp") or 0

                        messages.append({
                            "sender_name": sender_name,
                            "sender_id": str(sender_id),
                            "text": text,
                            "timestamp": timestamp,
                            "conversation_urn": str(chat_id),
                            "attendee_ids": attendee_ids,
                        })

            return messages
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Failed to fetch chats via backend: {e}")
            return []

    # ── DM Messaging ──

    async def send_message(
        self,
        account_id: str,
        chat_id: str,
        text: str,
    ) -> dict[str, Any]:
        """Send a DM message in an existing LinkedIn chat via the backend proxy."""
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/message"
        payload = {"chat_id": chat_id, "text": text}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            logger.info(
                "send_message chat_id=%s http_status=%d body=%s",
                chat_id[:30], resp.status_code, resp.text[:500],
            )
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Send failed: {e}"
        if not result["success"]:
            logger.warning("send_message failed: %s", result)
        return result

    async def send_new_message(
        self,
        account_id: str,
        provider_id: str,
        text: str,
    ) -> dict[str, Any]:
        """Start a new DM conversation with a connected LinkedIn user via backend proxy.

        Creates a new chat and sends the first message. Use when no existing
        chat exists (e.g., first follow-up after connection acceptance).

        Args:
            account_id: The user's Unipile account ID.
            provider_id: LinkedIn provider_id of the recipient.
            text: The message text to send.

        Returns:
            {"success": bool, "error": str, "chat_id": str}
        """
        result: dict[str, Any] = {"success": False, "error": "", "chat_id": ""}
        url = f"{self.base_url}/api/v1/chats"
        payload = {"attendees_ids": [provider_id], "text": text}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            logger.info(
                "send_new_message provider_id=%s http_status=%d body=%s",
                provider_id[:30], resp.status_code, resp.text[:500],
            )
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)
            if result["success"]:
                result["chat_id"] = data.get("chat_id") or data.get("id") or ""
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Send new message failed: {e}"
        if not result["success"]:
            logger.warning("send_new_message failed: %s", result)
        return result

    # ── Voice Messages ──

    async def send_voice_message(
        self,
        account_id: str,
        chat_id: str,
        audio_path: str,
        text: str = "",
    ) -> dict[str, Any]:
        """Send a voice message in an existing LinkedIn chat via backend proxy.

        Reads the audio file, base64-encodes it, and sends to the backend
        ``POST /api/v1/voice/send`` endpoint which decodes and forwards to
        Unipile as multipart/form-data.

        Returns:
            {"success": bool, "error": str}
        """
        import base64

        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/voice/send"
        try:
            with open(audio_path, "rb") as f:
                audio_b64 = base64.b64encode(f.read()).decode()
        except FileNotFoundError:
            result["error"] = f"Audio file not found: {audio_path}"
            return result
        payload = {
            "chat_id": chat_id,
            "audio_base64": audio_b64,
            "text": text,
        }
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Voice send failed: {e}"
        return result

    async def send_new_voice_message(
        self,
        account_id: str,
        provider_id: str,
        audio_path: str,
        text: str = "",
    ) -> dict[str, Any]:
        """Start a new DM with a voice message via backend proxy.

        Returns:
            {"success": bool, "error": str, "chat_id": str}
        """
        import base64

        result: dict[str, Any] = {"success": False, "error": "", "chat_id": ""}
        url = f"{self.base_url}/api/v1/voice/send"
        try:
            with open(audio_path, "rb") as f:
                audio_b64 = base64.b64encode(f.read()).decode()
        except FileNotFoundError:
            result["error"] = f"Audio file not found: {audio_path}"
            return result
        payload = {
            "provider_id": provider_id,
            "audio_base64": audio_b64,
            "text": text,
        }
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)
            if result["success"]:
                result["chat_id"] = data.get("chat_id") or ""
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Send new voice message failed: {e}"
        return result

    async def get_chat_messages(
        self,
        account_id: str,
        chat_id: str,
        limit: int = 20,
    ) -> list[dict[str, Any]]:
        """Fetch messages from a specific chat via the backend proxy."""
        url = f"{self.base_url}/api/v1/chats/{chat_id}/messages"
        params = {"limit": str(limit)}
        try:
            try:
                resp = await self._retry_request("GET", url, params=params)
            except (httpx.ConnectError, httpx.TimeoutException) as e:
                raise _wrap_connection_error(e, self.base_url)
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code == 400:
                raise UnipileError("No LinkedIn account connected.")
            resp.raise_for_status()
            data = resp.json()

            items = data.get("items") or data.get("messages") or data.get("data") or []
            if isinstance(data, list):
                items = data

            messages: list[dict[str, Any]] = []
            for msg in items[:limit]:
                if not isinstance(msg, dict):
                    continue
                text = msg.get("text") or msg.get("body") or msg.get("content") or ""
                sender_id = msg.get("sender_id") or msg.get("sender", {}).get("provider_id", "")
                sender_name = msg.get("sender_name") or msg.get("sender", {}).get("display_name", "")
                ts_raw = msg.get("timestamp") or msg.get("created_at") or msg.get("date") or ""
                timestamp = 0
                if isinstance(ts_raw, (int, float)):
                    timestamp = int(ts_raw)
                elif isinstance(ts_raw, str) and ts_raw:
                    try:
                        from datetime import datetime
                        timestamp = int(datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).timestamp())
                    except (ValueError, TypeError):
                        pass
                message_id = str(msg.get("id") or msg.get("message_id") or "")
                messages.append({
                    "message_id": message_id,
                    "sender_id": str(sender_id),
                    "sender_name": str(sender_name),
                    "text": text,
                    "timestamp": timestamp,
                })
            return messages
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Failed to fetch chat messages via backend: {e}")
            return []

    async def verify_message_sent(
        self,
        account_id: str,
        chat_id: str,
        expected_text: str,
    ) -> bool:
        """Read back conversation to verify our message was actually delivered.

        Checks the most recent messages in the chat for a match against
        the expected text (first 50 chars). Returns True if found.
        """
        try:
            messages = await self.get_chat_messages(account_id, chat_id, limit=3)
            snippet = expected_text[:50]
            for msg in messages:
                if snippet in (msg.get("text") or ""):
                    return True
            return False
        except Exception as e:
            logger.warning("DM verification failed for chat %s: %s", chat_id, e)
            return False

    # ── User Posts (for any user) ──

    async def get_followers(
        self,
        account_id: str,
        limit: int = 100,
        company_id: str = "",
    ) -> list[dict[str, Any]]:
        """Fetch followers via the backend proxy.

        If company_id is provided, fetches company page followers via Voyager.
        Otherwise falls back to the legacy user followers endpoint.
        """
        if company_id:
            url = f"{self.base_url}/api/v1/linkedin/company/{company_id}/followers"
            params = {"count": str(limit)}
        else:
            url = f"{self.base_url}/api/v1/users/followers"
            params = {"limit": str(limit)}
        try:
            resp = await self._retry_request("GET", url, params=params)
            if resp.status_code in (404, 400):
                logger.warning(
                    "get_followers: status=%d company_id=%s body=%s",
                    resp.status_code, company_id or "personal",
                    str(resp.text)[:300],
                )
                return []
            resp.raise_for_status()
            data = resp.json()

            # Check for voyager error forwarded by backend
            voyager_status = data.get("_status_code") if isinstance(data, dict) else None
            if voyager_status and voyager_status not in (200, 201):
                logger.warning(
                    "get_followers: voyager_status=%s company_id=%s",
                    voyager_status, company_id or "personal",
                )

            items = []
            if isinstance(data, list):
                items = data
            elif isinstance(data, dict):
                items = data.get("items") or data.get("data") or data.get("followers") or []

            followers: list[dict[str, Any]] = []
            for item in items[:limit]:
                if not isinstance(item, dict):
                    continue
                provider_id = item.get("provider_id") or item.get("id") or ""
                name = item.get("name") or item.get("full_name") or ""
                if not provider_id and not name:
                    continue
                followers.append({
                    "provider_id": str(provider_id),
                    "name": str(name),
                    "headline": str(item.get("headline") or ""),
                    "profile_url": str(item.get("profile_url") or item.get("public_identifier") or ""),
                })
            logger.info(
                "get_followers: found %d followers for company_id=%s",
                len(followers), company_id or "personal",
            )
            return followers
        except Exception as e:
            logger.warning("Failed to fetch followers via backend: %s (company_id=%s)", e, company_id or "personal")
            return []

    async def get_company_feed(
        self,
        account_id: str,
        company_id: str,
        limit: int = 10,
    ) -> list[dict[str, Any]]:
        """Fetch company page posts via the Voyager company feed endpoint.

        Args:
            account_id: Unipile account ID.
            company_id: LinkedIn company numeric ID.
            limit: Max posts to return.

        Returns:
            List of post dicts with id, social_id, text, likes, comments.
        """
        url = f"{self.base_url}/api/v1/linkedin/company/{company_id}/feed"
        params = {"count": str(limit)}
        try:
            resp = await self._retry_request("GET", url, params=params)
            if resp.status_code in (404, 400):
                logger.warning(
                    "get_company_feed: status=%d company=%s body=%s",
                    resp.status_code, company_id, str(resp.text)[:300],
                )
                return []
            resp.raise_for_status()
            data = resp.json()

            posts = data.get("posts", [])
            voyager_status = data.get("_status_code") if isinstance(data, dict) else None
            if voyager_status:
                logger.warning(
                    "get_company_feed: voyager_status=%s company=%s",
                    voyager_status, company_id,
                )
                return []
            logger.info("get_company_feed: found %d posts for company=%s", len(posts) if isinstance(posts, list) else 0, company_id)
            return posts if isinstance(posts, list) else []
        except Exception as e:
            logger.warning("Failed to fetch company feed via backend: %s (company=%s)", e, company_id)
            return []

    async def get_user_posts(
        self,
        account_id: str,
        identifier: str,
        limit: int = 10,
    ) -> list[dict[str, Any]]:
        """Fetch recent posts for any LinkedIn user via the backend proxy.

        Returns a list of post dicts. If the Unipile API returned a non-200
        status (e.g. 422), the list will be empty but a ``_status_code`` key
        will be present on the *first* element as a sentinel so callers can
        detect the error and fall back to ``search_posts``.
        """
        url = f"{self.base_url}/api/v1/users/{identifier}/posts"
        params = {"limit": str(limit)}
        try:
            resp = await self._retry_request("GET", url, params=params)
            if resp.status_code in (404, 400):
                return []
            resp.raise_for_status()
            data = resp.json()

            # Detect _status_code from backend (Unipile returned non-200)
            upstream_status = None
            items = []
            if isinstance(data, list):
                items = data
            elif isinstance(data, dict):
                upstream_status = data.get("_status_code")
                items = data.get("items") or data.get("data") or data.get("posts") or []

            # If backend flagged an upstream error (e.g. 422), return a
            # sentinel so callers can detect and fall back to search_posts.
            if upstream_status and upstream_status not in (200, 201):
                return [{"_status_code": upstream_status}]

            posts: list[dict[str, Any]] = []
            for item in items[:limit]:
                if not isinstance(item, dict):
                    continue
                text = item.get("text") or item.get("body") or item.get("content") or ""
                if not text:
                    continue
                post_id = item.get("id") or item.get("urn") or item.get("social_id") or ""
                post_date = item.get("date") or item.get("created_at") or item.get("parsed_datetime") or ""
                metrics = item.get("metrics") or item.get("social_counts") or {}
                if not isinstance(metrics, dict):
                    metrics = {}

                # Merge top-level counters into metrics
                for counter_key, metric_key in [
                    ("reaction_counter", "reactions_count"),
                    ("comment_counter", "comments_count"),
                    ("impressions_counter", "impressions_count"),
                    ("repost_counter", "reposts_count"),
                ]:
                    val = item.get(counter_key) or item.get(metric_key)
                    if val and metric_key not in metrics:
                        metrics[metric_key] = val

                post_data: dict[str, Any] = {
                    "id": str(post_id),
                    "text": str(text),
                    "date": str(post_date),
                    "metrics": metrics,
                    "share_url": item.get("share_url") or "",
                    "is_repost": bool(item.get("is_repost")),
                    "is_edited": bool(item.get("is_edited")),
                    "visibility": item.get("visibility") or "",
                }

                # Forward raw fields for expanded extraction downstream
                for fwd_key in ("image", "images", "video", "video_url", "article",
                                "article_url", "document", "document_url", "poll",
                                "reshared", "repost", "shared_post", "original_post_id",
                                "attachments"):
                    if item.get(fwd_key) is not None:
                        post_data[fwd_key] = item[fwd_key]

                posts.append(post_data)
            return posts
        except Exception as e:
            logger.warning(f"Failed to fetch user posts via backend: {e}")
            return []

    # ── Post Engagement ──

    async def send_post_comment(
        self,
        account_id: str,
        post_id: str,
        text: str,
        outreach_id: str = "",
        post_text: str = "",
    ) -> dict[str, Any]:
        """Comment on a LinkedIn post via the backend proxy."""
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/posts/comment"
        payload: dict[str, str] = {"post_id": post_id, "text": text}
        if outreach_id:
            payload["outreach_id"] = outreach_id
        if post_text:
            payload["post_text"] = post_text[:500]
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result, accept_codes=(200, 201, 202))
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Comment failed: {e}"
        return result

    async def send_post_reaction(
        self,
        account_id: str,
        post_id: str,
        reaction_type: str = "LIKE",
        outreach_id: str = "",
        post_text: str = "",
    ) -> dict[str, Any]:
        """React to a LinkedIn post via the backend proxy."""
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/posts/react"
        payload: dict[str, str] = {"post_id": post_id, "reaction_type": reaction_type}
        if outreach_id:
            payload["outreach_id"] = outreach_id
        if post_text:
            payload["post_text"] = post_text[:500]
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result, accept_codes=(200, 201, 202))
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Reaction failed: {e}"
        return result

    # ── Relations ──

    async def get_relations(
        self,
        account_id: str,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """Fetch LinkedIn connections/relations via the backend proxy."""
        url = f"{self.base_url}/api/v1/relations"
        params = {"limit": str(limit)}
        try:
            try:
                resp = await self._client.get(url, params=params, headers=self._headers())
            except (httpx.ConnectError, httpx.TimeoutException) as e:
                raise _wrap_connection_error(e, self.base_url)
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code == 400:
                raise UnipileError("No LinkedIn account connected.")
            resp.raise_for_status()
            data = resp.json()

            items = data.get("items") or data.get("relations") or data.get("data") or []
            if isinstance(data, list):
                items = data

            relations: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                provider_id = item.get("provider_id") or item.get("id") or ""
                name = item.get("display_name") or item.get("name") or ""
                if not name and (item.get("first_name") or item.get("last_name")):
                    name = f"{item.get('first_name', '')} {item.get('last_name', '')}".strip()
                headline = item.get("headline") or ""
                public_id = item.get("public_identifier") or item.get("publicIdentifier") or ""
                relations.append({
                    "provider_id": str(provider_id),
                    "name": name,
                    "headline": headline,
                    "public_id": public_id,
                })
            return relations
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Failed to fetch relations via backend: {e}")
            return []

    # ── InMail ──

    async def send_inmail(
        self,
        account_id: str,
        provider_id: str,
        subject: str,
        body: str,
    ) -> dict[str, Any]:
        """Send an InMail to a non-connection via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            provider_id: LinkedIn provider_id of the recipient.
            subject: InMail subject line.
            body: InMail message body.

        Returns:
            {"success": bool, "error": str, "chat_id": str}
        """
        result: dict[str, Any] = {"success": False, "error": "", "chat_id": ""}
        url = f"{self.base_url}/api/v1/linkedin/inmail"
        payload = {"provider_id": provider_id, "subject": subject, "body": body}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            resp.raise_for_status()
            data = resp.json()
            status_code = data.get("status_code", 200)
            if status_code in (200, 201):
                result["success"] = True
                inner = data.get("body", {})
                if isinstance(inner, dict):
                    result["chat_id"] = inner.get("chat_id") or inner.get("id") or ""
            elif status_code == 402:
                result["error"] = "No InMail credits remaining."
            elif status_code == 429:
                result["error"] = "Rate limited by LinkedIn."
            else:
                result["error"] = f"Unipile returned {status_code}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"InMail send failed: {e}"
        return result

    async def get_inmail_balance(
        self,
        account_id: str,
    ) -> dict[str, Any]:
        """Get InMail credit balance via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).

        Returns:
            {"credits": int, "error": str}
        """
        result: dict[str, Any] = {"credits": -1, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/inmail-balance"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code in (200, 201):
                data = resp.json()
                result["credits"] = data.get("balance") or data.get("credits") or data.get("remaining") or 0
            else:
                result["error"] = f"Status {resp.status_code}"
        except Exception as e:
            result["error"] = f"InMail balance check failed: {e}"
        return result

    # ── Skill Endorsement ──

    async def endorse_skill(
        self,
        account_id: str,
        identifier: str,
    ) -> dict[str, Any]:
        """Endorse a LinkedIn profile's skills via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            identifier: Profile public_id or provider_id.

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/profile/{identifier}/skill/endorse"
        try:
            resp = await self._retry_request("POST", url)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            status_code = data.get("status_code", 200)
            if status_code in (200, 201, 204):
                result["success"] = True
            elif status_code == 404:
                result["error"] = "Profile or skills not found."
            elif status_code == 422:
                result["error"] = "Cannot endorse skills (may require connection)."
            elif status_code == 429:
                result["error"] = "Rate limited by LinkedIn."
            else:
                result["error"] = f"Unipile returned {status_code}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Skill endorsement failed: {e}"
        return result

    # ── Company Profile ──

    async def get_company_profile(
        self,
        account_id: str,
        identifier: str,
    ) -> dict[str, Any]:
        """Fetch a LinkedIn company profile via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            identifier: Company public_id, provider_id, or URL slug.

        Returns:
            Company dict with name, industry, size, employee_count, etc.
        """
        url = f"{self.base_url}/api/v1/linkedin/company/{identifier}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code in (404, 400):
                return {}
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            resp.raise_for_status()
            data = resp.json()
            if not isinstance(data, dict):
                return {}
            return {
                "name": data.get("name") or data.get("company_name") or "",
                "industry": data.get("industry") or "",
                "size": data.get("company_size") or data.get("size") or "",
                "employee_count": data.get("employee_count") or data.get("employees_count") or 0,
                "description": data.get("description") or data.get("about") or "",
                "website": data.get("website") or data.get("url") or "",
                "headquarters": data.get("headquarters") or data.get("location") or "",
                "founded": data.get("founded") or data.get("founded_year") or "",
                "specialties": data.get("specialties") or [],
                "type": data.get("type") or data.get("company_type") or "",
                "provider_id": str(data.get("provider_id") or data.get("id") or ""),
            }
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Company profile fetch error: {e}")
            return {}

    # ── Inbound Invitations ──

    async def get_received_invitations(
        self,
        account_id: str,
    ) -> list[dict[str, Any]]:
        """Fetch received (inbound) LinkedIn invitations via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).

        Returns:
            List of invitation dicts with id, sender_name, sender_id, headline, message, timestamp.
        """
        url = f"{self.base_url}/api/v1/linkedin/invitations/received"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code != 200:
                return []
            data = resp.json()

            # Diagnostic logging — raw response shape
            if isinstance(data, dict):
                items_key = "items" if "items" in data else ("data" if "data" in data else None)
                raw_count = len(data.get(items_key, [])) if items_key else 0
                logger.info("get_received_invitations (backend): keys=%s, items_key=%s, raw_count=%d",
                            list(data.keys()), items_key, raw_count)
            elif isinstance(data, list):
                logger.info("get_received_invitations (backend): list count=%d", len(data))

            items = data.get("items") or data.get("data") or []
            if isinstance(data, list):
                items = data

            invitations: list[dict[str, Any]] = []
            for inv in items:
                if not isinstance(inv, dict):
                    continue
                inv_id = inv.get("id") or inv.get("invitation_id") or ""
                if not inv_id:
                    continue

                # Skip non-connection invitations (newsletters, events, etc.)
                if _is_newsletter_invitation(inv):
                    logger.debug("Filtered non-connection invitation: id=%s", inv_id)
                    continue

                # Sender info — expanded field coverage + nested objects
                sender_name = inv.get("sender_name") or inv.get("display_name") or inv.get("name") or ""
                if not sender_name:
                    fn = inv.get("first_name") or inv.get("firstName") or ""
                    ln = inv.get("last_name") or inv.get("lastName") or ""
                    sender_name = f"{fn} {ln}".strip()
                sender_id = (
                    inv.get("provider_id") or inv.get("sender_id")
                    or inv.get("from_member_id") or inv.get("inviter_id")
                    or inv.get("from_id") or inv.get("member_id") or ""
                )
                # Check nested sender/inviter objects
                for nested_key in ("sender", "inviter", "from", "user"):
                    nested = inv.get(nested_key)
                    if isinstance(nested, dict):
                        sender_id = sender_id or nested.get("id") or nested.get("provider_id") or ""
                        if not sender_name:
                            sender_name = nested.get("name") or nested.get("display_name") or ""
                            if not sender_name:
                                fn = nested.get("first_name") or nested.get("firstName") or ""
                                ln = nested.get("last_name") or nested.get("lastName") or ""
                                sender_name = f"{fn} {ln}".strip()

                headline = inv.get("headline") or ""
                message = inv.get("message") or inv.get("custom_message") or ""

                ts_raw = inv.get("timestamp") or inv.get("created_at") or inv.get("date") or ""
                timestamp = 0
                if isinstance(ts_raw, (int, float)):
                    timestamp = int(ts_raw)
                elif isinstance(ts_raw, str) and ts_raw:
                    try:
                        from datetime import datetime
                        timestamp = int(datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).timestamp())
                    except (ValueError, TypeError):
                        pass
                # Extract shared_secret for acceptance
                specifics = inv.get("specifics") or {}
                shared_secret = specifics.get("shared_secret") or ""

                invitations.append({
                    "id": str(inv_id),
                    "sender_name": sender_name,
                    "sender_id": str(sender_id),
                    "headline": headline,
                    "message": message,
                    "timestamp": timestamp,
                    "shared_secret": shared_secret,
                })

            logger.info("get_received_invitations (backend): %d raw -> %d after filter",
                        len(items), len(invitations))
            return invitations
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning(f"Failed to fetch received invitations via backend: {e}")
            return []

    async def handle_invitation(
        self,
        account_id: str,
        invitation_id: str,
        action: str = "accept",
        *,
        shared_secret: str = "",
    ) -> dict[str, Any]:
        """Accept or decline a received invitation via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            invitation_id: The invitation ID.
            action: "accept" or "decline".
            shared_secret: LinkedIn shared secret from invitation specifics.

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/invitations/received/{invitation_id}"
        payload: dict[str, Any] = {"action": action}
        if shared_secret:
            payload["shared_secret"] = shared_secret
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            status_code = data.get("status_code", 200)
            if status_code in (200, 201, 204):
                result["success"] = True
            elif status_code == 404:
                result["error"] = "Invitation not found or already handled."
            else:
                result["error"] = f"Unipile returned {status_code}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Handle invitation failed: {e}"
        return result

    async def withdraw_invitation(
        self,
        account_id: str,
        invitation_id: str,
    ) -> dict[str, Any]:
        """Withdraw a sent LinkedIn invitation via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            invitation_id: The invitation ID to withdraw.

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/invitations/{invitation_id}"
        try:
            resp = await self._client.delete(url, headers=self._headers())
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code in (200, 201, 204):
                result["success"] = True
            elif resp.status_code == 404:
                result["error"] = "Invitation not found."
            else:
                body = resp.text[:200]
                result["error"] = f"Unipile returned {resp.status_code}: {body}"
        except Exception as e:
            result["error"] = f"Withdraw failed: {e}"
        return result

    # ── Get Pending (Sent) Invitations ──

    async def get_pending_invitations(
        self,
        account_id: str,
    ) -> list[dict[str, Any]]:
        """Fetch all pending sent LinkedIn invitations via the backend proxy.

        The backend handles cursor pagination server-side.

        Returns:
            List of invitation dicts (id, provider_id, name, timestamp, etc.).
        """
        url = f"{self.base_url}/api/v1/linkedin/invitations/sent"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code == 401:
                logger.warning("get_pending_invitations: backend JWT expired")
                return []
            if resp.status_code != 200:
                logger.debug("get_pending_invitations: status %d", resp.status_code)
                return []
            data = resp.json()
            items = data.get("items") or []
            logger.info("get_pending_invitations: fetched %d invitations", len(items))
            return [inv for inv in items if isinstance(inv, dict)]
        except Exception as e:
            logger.debug("get_pending_invitations error: %s", e)
            return []

    async def delete_message(
        self,
        account_id: str,
        message_id: str,
    ) -> dict[str, Any]:
        """Delete a LinkedIn message via the backend proxy.

        LinkedIn only allows deletion within 60 minutes of sending.

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/messages/{message_id}"
        try:
            resp = await self._client.delete(url, headers=self._headers())
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code in (200, 201, 204):
                # Double-check inner status_code from proxy wrapper
                try:
                    data = resp.json()
                    inner_status = data.get("status_code", 200)
                    if isinstance(inner_status, int) and inner_status >= 400:
                        detail = data.get("body", {})
                        result["error"] = (
                            f"Unipile returned {inner_status}: "
                            f"{detail.get('detail', detail) if isinstance(detail, dict) else str(detail)[:200]}"
                        )
                        return result
                except (ValueError, AttributeError):
                    pass
                result["success"] = True
            elif resp.status_code == 404:
                result["error"] = "Message not found on LinkedIn."
            else:
                body = resp.text[:200]
                result["error"] = f"Unipile returned {resp.status_code}: {body}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Delete message failed: {e}"
        return result

    # ── Messages (comprehensive) ──

    async def list_all_messages(
        self,
        account_id: str,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """Fetch ALL messages across all conversations via the backend proxy.

        GET /api/v1/messages

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            limit: Max messages to return.

        Returns:
            List of message dicts with sender_id, sender_name, text,
            timestamp, chat_id, message_id.
        """
        url = f"{self.base_url}/api/v1/linkedin/messages?limit={limit}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code != 200:
                return []
            data = resp.json()

            items = data.get("items") or data.get("messages") or data.get("data") or []
            if isinstance(data, list):
                items = data

            messages: list[dict[str, Any]] = []
            for msg in items[:limit]:
                if not isinstance(msg, dict):
                    continue
                text = msg.get("text") or msg.get("body") or msg.get("content") or ""
                sender_id = msg.get("sender_id") or msg.get("sender", {}).get("provider_id", "")
                sender_name = msg.get("sender_name") or msg.get("sender", {}).get("display_name", "")
                chat_id = msg.get("chat_id") or msg.get("conversation_id") or ""
                message_id = msg.get("id") or msg.get("message_id") or ""
                ts_raw = msg.get("timestamp") or msg.get("created_at") or msg.get("date") or ""
                timestamp = 0
                if isinstance(ts_raw, (int, float)):
                    timestamp = int(ts_raw)
                elif isinstance(ts_raw, str) and ts_raw:
                    try:
                        from datetime import datetime
                        timestamp = int(datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).timestamp())
                    except (ValueError, TypeError):
                        pass
                messages.append({
                    "sender_id": str(sender_id),
                    "sender_name": str(sender_name),
                    "text": text,
                    "timestamp": timestamp,
                    "chat_id": str(chat_id),
                    "message_id": str(message_id),
                })
            return messages
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning("Failed to list all messages via backend: %s", e)
            return []

    async def get_messages_by_sender(
        self,
        account_id: str,
        sender_id: str,
        limit: int = 20,
    ) -> list[dict[str, Any]]:
        """Fetch messages from a specific person via the backend proxy.

        GET /api/v1/chat_attendees/{sender_id}/messages

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            sender_id: The attendee's provider_id or public_id.
            limit: Max messages to return.

        Returns:
            List of message dicts with sender_id, sender_name, text,
            timestamp, chat_id, message_id.
        """
        url = f"{self.base_url}/api/v1/linkedin/chat_attendees/{sender_id}/messages?limit={limit}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code in (401, 403):
                raise UnipileAuthError()
            if resp.status_code != 200:
                return []
            data = resp.json()

            items = data.get("items") or data.get("messages") or data.get("data") or []
            if isinstance(data, list):
                items = data

            messages: list[dict[str, Any]] = []
            for msg in items[:limit]:
                if not isinstance(msg, dict):
                    continue
                text = msg.get("text") or msg.get("body") or msg.get("content") or ""
                msg_sender_id = msg.get("sender_id") or msg.get("sender", {}).get("provider_id", "")
                sender_name = msg.get("sender_name") or msg.get("sender", {}).get("display_name", "")
                chat_id = msg.get("chat_id") or msg.get("conversation_id") or ""
                message_id = msg.get("id") or msg.get("message_id") or ""
                ts_raw = msg.get("timestamp") or msg.get("created_at") or msg.get("date") or ""
                timestamp = 0
                if isinstance(ts_raw, (int, float)):
                    timestamp = int(ts_raw)
                elif isinstance(ts_raw, str) and ts_raw:
                    try:
                        from datetime import datetime
                        timestamp = int(datetime.fromisoformat(ts_raw.replace("Z", "+00:00")).timestamp())
                    except (ValueError, TypeError):
                        pass
                messages.append({
                    "sender_id": str(msg_sender_id),
                    "sender_name": str(sender_name),
                    "text": text,
                    "timestamp": timestamp,
                    "chat_id": str(chat_id),
                    "message_id": str(message_id),
                })
            return messages
        except UnipileAuthError:
            raise
        except Exception as e:
            logger.warning("Failed to get messages by sender via backend: %s", e)
            return []

    async def add_message_reaction(
        self,
        account_id: str,
        message_id: str,
        reaction: str = "\U0001f44d",
    ) -> dict[str, Any]:
        """React to a LinkedIn DM message via the backend proxy.

        POST /api/v1/messages/{message_id}/reaction

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            message_id: The message ID to react to.
            reaction: Emoji reaction (default: thumbs up).

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/messages/{message_id}/reaction"
        payload = {"reaction": reaction}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code in (200, 201, 204):
                result["success"] = True
            elif resp.status_code == 404:
                result["error"] = "Message not found."
            else:
                body = resp.text[:200]
                result["error"] = f"Unipile returned {resp.status_code}: {body}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Add reaction failed: {e}"
        return result

    # ── Follow ──

    async def follow_profile(
        self,
        account_id: str,
        provider_id: str,
    ) -> dict[str, Any]:
        """Follow a LinkedIn profile via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            provider_id: The prospect's LinkedIn provider_id.

        Returns:
            {"success": bool, "error": str}
        """
        if not voyager_health.is_healthy("follow"):
            logger.info("follow_profile: skipped — Voyager 'follow' endpoint unhealthy")
            return {"success": False, "error": "Voyager follow endpoint unavailable", "voyager_down": True}
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/follow"
        payload = {"provider_id": provider_id}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                voyager_health.record("follow", success=False)
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            _classify_nested_response(data, result)
            voyager_health.record("follow", success=result.get("success", False))
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Follow failed: {e}"
            voyager_health.record("follow", success=False)
        return result

    async def check_follow_status(
        self,
        account_id: str,
        provider_id: str,
    ) -> dict[str, Any]:
        """Check if we follow a LinkedIn profile via backend proxy.

        Returns: {"following": bool, "error": str}
        """
        result: dict[str, Any] = {"following": False, "error": ""}
        if not provider_id:
            result["error"] = "No provider_id"
            return result
        if not voyager_health.is_healthy("check_follow"):
            logger.info("check_follow_status: skipped — Voyager endpoint unhealthy")
            result["error"] = "Voyager check-follow endpoint unavailable"
            result["voyager_down"] = True
            return result
        url = f"{self.base_url}/api/v1/linkedin/check-follow"
        payload = {"provider_id": provider_id}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                voyager_health.record("check_follow", success=False)
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            result["following"] = bool(data.get("following", False))
            voyager_health.record("check_follow", success=True)
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = str(e)
            voyager_health.record("check_follow", success=False)
        return result

    async def view_profile(
        self,
        account_id: str,
        provider_id: str,
    ) -> dict[str, Any]:
        """View a LinkedIn profile via the backend proxy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).
            provider_id: The prospect's LinkedIn provider_id or public_id.

        Returns:
            {"success": bool, "error": str}
        """
        result: dict[str, Any] = {"success": False, "error": ""}
        url = f"{self.base_url}/api/v1/linkedin/view-profile"
        payload = {"provider_id": provider_id}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code == 401:
                result["error"] = "Backend JWT expired."
                return result
            if resp.status_code == 400:
                result["error"] = "No LinkedIn account connected."
                return result
            resp.raise_for_status()
            data = resp.json()
            status_code = data.get("status_code", 200)
            if status_code in (200, 201):
                result["success"] = True
            else:
                result["error"] = f"Unipile returned {status_code}"
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            result["error"] = str(_wrap_connection_error(e, self.base_url))
        except Exception as e:
            result["error"] = f"Profile view failed: {e}"
        return result

    # ── Profile Viewers ──

    async def get_profile_viewers(
        self,
        account_id: str,
    ) -> list[dict[str, Any]]:
        """Get list of people who viewed your LinkedIn profile via the backend proxy.

        Tries the v2 (wvmpCards) endpoint first (more reliable), then falls
        back to the v1 (GraphQL) endpoint only if the tracker says v1 is healthy.

        Args:
            account_id: The Unipile account ID (unused, taken from backend JWT).

        Returns:
            List of viewer dicts with name, title, company, relation, url.
        """
        # Try v2 (wvmpCards) first — more reliable
        url_v2 = f"{self.base_url}/api/v1/linkedin/profile-viewers-v2"
        try:
            resp = await self._client.get(url_v2, headers=self._headers())
            if resp.status_code == 200:
                data = resp.json()
                viewers = data.get("viewers", [])
                voyager_health.record("profile_viewers_v2", success=True)
                if viewers:
                    logger.info("get_profile_viewers v2: found %d viewers", len(viewers))
                    return viewers
                logger.debug("get_profile_viewers v2: 200 but 0 viewers, trying v1")
            else:
                voyager_health.record("profile_viewers_v2", success=False)
                logger.warning(
                    "get_profile_viewers v2: status=%d body=%s",
                    resp.status_code, str(resp.text)[:300],
                )
        except Exception as e:
            voyager_health.record("profile_viewers_v2", success=False)
            logger.warning("get_profile_viewers v2 exception: %s", e)

        # Fall back to v1 (GraphQL) only if it's been healthy recently
        if not voyager_health.is_healthy("profile_viewers_v1"):
            logger.info("get_profile_viewers: skipping v1 — endpoint unhealthy")
            return []

        url = f"{self.base_url}/api/v1/linkedin/profile-viewers"
        try:
            resp = await self._client.get(url, headers=self._headers())
            data = resp.json() if resp.status_code == 200 else {}
            if resp.status_code == 200:
                viewers = data.get("viewers", [])
                voyager_health.record("profile_viewers_v1", success=bool(viewers))
                if viewers:
                    logger.info("get_profile_viewers v1: found %d viewers", len(viewers))
                    return viewers
                logger.debug("get_profile_viewers v1: 200 but 0 viewers")
            else:
                voyager_health.record("profile_viewers_v1", success=False)
                logger.warning(
                    "get_profile_viewers v1: status=%d body=%s",
                    resp.status_code, str(resp.text)[:300],
                )
        except Exception as e:
            voyager_health.record("profile_viewers_v1", success=False)
            logger.warning("get_profile_viewers v1 exception: %s", e)

        logger.warning("get_profile_viewers: both v1 and v2 returned no viewers")
        return []

    # ── Post Search ──

    async def search_posts(
        self,
        account_id: str,
        keywords: str,
        limit: int = 25,
        *,
        cursor: str | None = None,
    ) -> tuple[list[dict[str, Any]], str | None]:
        """Search LinkedIn posts by keywords via the backend proxy."""
        url = f"{self.base_url}/api/v1/linkedin/search/posts"
        payload: dict[str, Any] = {
            "keywords": keywords,
            "limit": min(limit, 50),
        }
        if cursor:
            payload["cursor"] = cursor
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code != 200:
                return [], None
            data = resp.json()

            items = data.get("items") or data.get("results") or data.get("data") or []
            if isinstance(data, list):
                items = data

            next_cursor = None
            if isinstance(data, dict):
                next_cursor = data.get("cursor") or data.get("next_cursor") or data.get("paging", {}).get("cursor")

            posts: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                author = item.get("author") or {}
                if isinstance(author, str):
                    author = {"name": author}
                posts.append({
                    "post_id": item.get("id") or item.get("post_id") or "",
                    "text": item.get("text") or item.get("body") or item.get("content") or "",
                    "author_name": author.get("name") or author.get("display_name") or "",
                    "author_id": str(author.get("provider_id") or author.get("id") or ""),
                    "author_headline": author.get("headline") or "",
                    "author_url": author.get("profile_url") or author.get("public_profile_url") or "",
                    "reactions_count": item.get("reactions_count") or item.get("reaction_counter") or item.get("likes") or 0,
                    "comments_count": item.get("comments_count") or item.get("comment_counter") or item.get("comments") or 0,
                    "impressions_count": item.get("impressions_counter") or item.get("impressions_count") or item.get("views_count") or 0,
                    "reposts_count": item.get("repost_counter") or item.get("reposts_count") or item.get("shares_count") or 0,
                    "timestamp": item.get("timestamp") or item.get("created_at") or item.get("parsed_datetime") or "",
                    "share_url": item.get("share_url") or "",
                    "is_repost": bool(item.get("is_repost")),
                    "visibility": item.get("visibility") or "",
                    "is_company": bool(author.get("is_company")),
                })
            return posts, next_cursor
        except Exception as e:
            logger.warning(f"Backend post search error: {e}")
            return [], None

    # ── Company Search ──

    async def search_companies(
        self,
        account_id: str,
        keywords: str,
        limit: int = 25,
        *,
        cursor: str | None = None,
    ) -> tuple[list[dict[str, Any]], str | None]:
        """Search LinkedIn companies by keywords via the backend proxy."""
        url = f"{self.base_url}/api/v1/linkedin/search/companies"
        payload: dict[str, Any] = {
            "keywords": keywords,
            "limit": min(limit, 50),
        }
        if cursor:
            payload["cursor"] = cursor
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code != 200:
                return [], None
            data = resp.json()

            items = data.get("items") or data.get("results") or data.get("data") or []
            if isinstance(data, list):
                items = data

            next_cursor = None
            if isinstance(data, dict):
                next_cursor = data.get("cursor") or data.get("next_cursor") or data.get("paging", {}).get("cursor")

            companies: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                companies.append({
                    "company_id": item.get("id") or item.get("company_id") or "",
                    "name": item.get("name") or item.get("title") or "",
                    "industry": item.get("industry") or "",
                    "size": item.get("size") or item.get("company_size") or "",
                    "employee_count": item.get("employee_count") or item.get("employeeCount") or 0,
                    "description": item.get("description") or item.get("tagline") or "",
                    "website": item.get("website") or "",
                    "logo_url": item.get("logo_url") or item.get("logo") or "",
                    "linkedin_url": item.get("url") or item.get("linkedin_url") or item.get("public_profile_url") or "",
                    "location": item.get("location") or item.get("headquarters") or "",
                })
            return companies, next_cursor
        except Exception as e:
            logger.warning(f"Backend company search error: {e}")
            return [], None

    # ── Job Search (Intent Signals) ──

    async def search_jobs(
        self,
        account_id: str,
        keywords: str,
        limit: int = 25,
        *,
        cursor: str | None = None,
    ) -> tuple[list[dict[str, Any]], str | None]:
        """Search LinkedIn job postings by keywords via the backend proxy."""
        url = f"{self.base_url}/api/v1/linkedin/search/jobs"
        payload: dict[str, Any] = {
            "keywords": keywords,
            "limit": min(limit, 50),
        }
        if cursor:
            payload["cursor"] = cursor
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code != 200:
                return [], None
            data = resp.json()

            items = data.get("items") or data.get("results") or data.get("data") or []
            if isinstance(data, list):
                items = data

            next_cursor = None
            if isinstance(data, dict):
                next_cursor = data.get("cursor") or data.get("next_cursor") or data.get("paging", {}).get("cursor")

            jobs: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                company = item.get("company") or {}
                if isinstance(company, str):
                    company = {"name": company}
                jobs.append({
                    "job_id": item.get("id") or item.get("job_id") or "",
                    "title": item.get("title") or item.get("name") or "",
                    "company_name": company.get("name") or item.get("company_name") or "",
                    "company_id": str(company.get("id") or company.get("provider_id") or ""),
                    "company_url": company.get("url") or company.get("linkedin_url") or "",
                    "location": item.get("location") or "",
                    "description": (item.get("description") or item.get("body") or "")[:500],
                    "posted_at": item.get("posted_at") or item.get("timestamp") or item.get("created_at") or "",
                    "applicants": item.get("applicants") or item.get("applicant_count") or 0,
                    "linkedin_url": item.get("url") or item.get("linkedin_url") or "",
                })
            return jobs, next_cursor
        except Exception as e:
            logger.warning(f"Backend job search error: {e}")
            return [], None

    # ── SSI Score ──

    async def get_ssi_score(self, account_id: str) -> dict[str, Any]:
        """Get Social Selling Index (SSI) score via the backend proxy."""
        if not voyager_health.is_healthy("ssi"):
            logger.info("get_ssi_score: skipped — Voyager SSI endpoint unhealthy")
            return {}
        url = f"{self.base_url}/api/v1/linkedin/ssi-score"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code != 200:
                voyager_health.record("ssi", success=False)
                logger.warning(
                    "get_ssi_score: status=%d body=%s",
                    resp.status_code, str(resp.text)[:300],
                )
                return {}
            voyager_health.record("ssi", success=True)
            data = resp.json()
            return data
        except Exception as e:
            voyager_health.record("ssi", success=False)
            logger.warning("get_ssi_score exception: %s", e)
            return {}

    # ── Webhooks ──

    async def register_webhook(
        self,
        account_id: str,
        request_url: str,
        events: list[str] | None = None,
    ) -> dict[str, Any]:
        """Register a Unipile webhook via the backend proxy."""
        url = f"{self.base_url}/api/v1/webhooks"
        payload: dict[str, Any] = {"request_url": request_url}
        if events:
            payload["events"] = events
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                body = resp.json()
                webhook_id = body.get("body", {}).get("webhook_id") or body.get("body", {}).get("id") or ""
                return {"success": True, "webhook_id": webhook_id}
            return {"success": False, "error": resp.text}
        except Exception as e:
            return {"success": False, "error": str(e)}

    async def list_webhooks(self, account_id: str) -> list[dict[str, Any]]:
        """List registered webhooks via the backend proxy."""
        url = f"{self.base_url}/api/v1/webhooks"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code != 200:
                return []
            data = resp.json()
            if isinstance(data, list):
                return data
            return data.get("items") or data.get("webhooks") or data.get("data") or []
        except Exception:
            return []

    async def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
        """Delete a webhook via the backend proxy."""
        url = f"{self.base_url}/api/v1/webhooks/{webhook_id}"
        try:
            resp = await self._client.delete(url, headers=self._headers())
            if resp.status_code in (200, 204):
                return {"success": True}
            return {"success": False, "error": resp.text}
        except Exception as e:
            return {"success": False, "error": str(e)}

    # ── Email ──

    async def send_email(
        self,
        account_id: str,
        to_email: str,
        to_name: str,
        subject: str,
        body: str,
        reply_to_provider_id: str = "",
        tracking_label: str = "",
    ) -> dict[str, Any]:
        """Send an email via a connected email account (backend proxy)."""
        url = f"{self.base_url}/api/v1/email/send"
        payload: dict[str, Any] = {
            "account_id": account_id,
            "to_email": to_email,
            "to_name": to_name,
            "subject": subject,
            "body": body,
        }
        if reply_to_provider_id:
            payload["reply_to_provider_id"] = reply_to_provider_id
        if tracking_label:
            payload["tracking_label"] = tracking_label

        result: dict[str, Any] = {"success": False, "email_id": "", "error": ""}
        try:
            resp = await self._retry_request("POST", url, json=payload)
            if resp.status_code in (200, 201):
                data = resp.json()
                result["success"] = True
                result["email_id"] = data.get("email_id") or data.get("id") or ""
            elif resp.status_code == 400:
                result["error"] = "No email account connected."
            elif resp.status_code == 401:
                raise UnipileAuthError("Backend JWT expired or invalid.")
            else:
                result["error"] = f"Backend returned {resp.status_code}: {resp.text[:200]}"
        except UnipileAuthError:
            raise
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        except Exception as e:
            result["error"] = f"Email send failed: {e}"
        return result

    async def list_emails(
        self,
        account_id: str,
        limit: int = 20,
        folder: str = "",
    ) -> list[dict[str, Any]]:
        """List emails from connected email account (backend proxy)."""
        url = f"{self.base_url}/api/v1/email/list"
        params: dict[str, str] = {"limit": str(limit)}
        if folder:
            params["folder"] = folder
        try:
            resp = await self._client.get(url, params=params, headers=self._headers())
            if resp.status_code != 200:
                return []
            data = resp.json()
            return data.get("emails", [])
        except Exception as e:
            logger.debug(f"list_emails failed: {e}")
            return []

    async def get_email(
        self,
        account_id: str,
        email_id: str,
    ) -> dict[str, Any]:
        """Get a single email by ID (backend proxy)."""
        url = f"{self.base_url}/api/v1/email/{email_id}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code != 200:
                return {}
            return resp.json()
        except Exception as e:
            logger.debug(f"get_email failed: {e}")
            return {}

    async def mark_email(
        self,
        account_id: str,
        email_id: str,
        action: str = "setRead",
    ) -> dict[str, Any]:
        """Mark an email as read/unread/archived (backend proxy)."""
        url = f"{self.base_url}/api/v1/email/{email_id}/mark"
        payload = {"action": action}
        result: dict[str, Any] = {"success": False, "error": ""}
        try:
            resp = await self._client.patch(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 204):
                result["success"] = True
            else:
                result["error"] = f"Backend returned {resp.status_code}"
        except Exception as e:
            result["error"] = f"Mark email failed: {e}"
        return result

    # ── Webhook Event Polling ──

    async def get_unprocessed_events(
        self,
        account_id: str,
        limit: int = 50,
        event_type: str = "",
    ) -> list[dict[str, Any]]:
        """Fetch unprocessed webhook events from the backend."""
        url = f"{self.base_url}/webhooks/events/unprocessed"
        params: dict[str, str] = {"limit": str(limit)}
        if event_type:
            params["event_type"] = event_type
        try:
            resp = await self._client.get(url, params=params, headers=self._headers())
            if resp.status_code != 200:
                return []
            data = resp.json()
            return data.get("events", [])
        except Exception as e:
            logger.debug(f"get_unprocessed_events failed: {e}")
            return []

    async def acknowledge_event(self, event_id: int) -> bool:
        """Mark a webhook event as processed."""
        url = f"{self.base_url}/webhooks/events/{event_id}/ack"
        try:
            resp = await self._client.post(url, headers=self._headers())
            return resp.status_code == 200
        except Exception:
            return False

    # ── Search Parameters ──

    async def get_search_params(
        self, type: str, keywords: str, search_account_id: str | None = None,
    ) -> list[dict[str, str]]:
        """Look up LinkedIn search parameter codes via the backend proxy.

        Args:
            type: Parameter type (LOCATION, INDUSTRY, JOB_TITLE, DEPARTMENT, etc.)
            keywords: Search keywords to look up codes for.
            search_account_id: Optional override to route through a premium account.

        Returns:
            List of {name, code} dicts.
        """
        url = f"{self.base_url}/api/v1/search/parameters"
        payload: dict[str, Any] = {"type": type, "keywords": keywords}
        if search_account_id:
            payload["search_account_id"] = search_account_id
        resp = await self._retry_request("POST", url, json=payload)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 400:
            raise UnipileError(_NO_LINKEDIN_MSG)
        resp.raise_for_status()
        return resp.json().get("params", [])

    # ── Voice Memo Proxy ──

    async def generate_voice_memo(
        self,
        text: str,
        voice_config: dict[str, Any],
        voice_signature: dict[str, Any] | None = None,
        humanize: bool = True,
        noise_type: str = "auto",
        noise_volume: str = "subtle",
    ) -> tuple[str, float]:
        """Generate voice memo audio via backend enhanced voice pipeline.

        The backend runs text humanization, multi-utterance splitting,
        Hume TTS, and ambient noise overlay in a single request.

        Returns:
            Tuple of (audio_base64: str, duration_seconds: float).
        """
        url = f"{self.base_url}/api/v1/voice/generate"
        payload: dict[str, Any] = {
            "text": text,
            "voice_config": voice_config,
            "humanize": humanize,
            "noise_type": noise_type,
            "noise_volume": noise_volume,
        }
        if voice_signature:
            payload["voice_signature"] = voice_signature
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code >= 400:
            detail = resp.text[:200] if resp.text else f"HTTP {resp.status_code}"
            raise UnipileError(f"Voice memo generation failed: {detail}")
        data = resp.json()
        return data.get("audio_base64", ""), data.get("duration_seconds", 0.0)

    # ── LLM Proxy Methods ──

    async def analyze_voice(
        self, profile: dict[str, Any], posts: list[dict],
    ) -> dict[str, Any]:
        """Analyze voice signature via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/analyze-voice"
        payload = {"profile": profile, "posts": posts}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable — no GEMINI_API_KEY configured.")
        resp.raise_for_status()
        return resp.json()

    async def generate_icp(
        self, target_description: str, user_context: dict[str, Any],
    ) -> dict[str, Any]:
        """Generate ICP via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-icp"
        payload = {"target_description": target_description, "user_context": user_context}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return data.get("icp", data)

    async def generate_icp_rag(
        self,
        target_description: str,
        company_context: str = "",
        focus_query: str = "",
        user_context: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Generate ICP via the backend RAG pipeline (ingest → embed → retrieve → summarize → ICP)."""
        url = f"{self.base_url}/api/v1/llm/generate-icp-rag"
        payload = {
            "target_description": target_description,
            "company_context": company_context,
            "focus_query": focus_query,
            "user_context": user_context or {},
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return data.get("icp", data)

    async def generate_message(
        self,
        sender: dict[str, Any],
        prospect: dict[str, Any],
        voice: dict[str, Any],
        campaign_context: dict[str, Any],
        prospect_analysis: dict[str, Any] | None = None,
        conversation_history: list[dict] | None = None,
    ) -> str:
        """Generate a personalized message via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-message"
        payload = {
            "sender": sender,
            "prospect": prospect,
            "voice": voice,
            "campaign_context": campaign_context,
        }
        if prospect_analysis:
            payload["prospect_analysis"] = prospect_analysis
        if conversation_history:
            payload["conversation_history"] = conversation_history
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json().get("message", "")

    async def improve_message(
        self,
        draft: str,
        voice: dict[str, Any],
        message_type: str = "invitation",
        max_chars: int = 200,
    ) -> str:
        """Improve a message via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/improve-message"
        payload = {
            "draft": draft,
            "voice": voice,
            "message_type": message_type,
            "max_chars": max_chars,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json().get("message", draft)

    async def fix_message(
        self,
        message: str,
        issues: list[str],
        voice: dict[str, Any],
        message_type: str = "invitation",
        max_chars: int = 200,
    ) -> str:
        """Fix validation issues in a message via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/fix-message"
        payload = {
            "message": message,
            "issues": issues,
            "voice": voice,
            "message_type": message_type,
            "max_chars": max_chars,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json().get("message", message)

    async def classify_sentiment(self, text: str) -> str:
        """Classify sentiment via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/classify-sentiment"
        payload = {"text": text}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        sentiment = resp.json().get("sentiment")
        return sentiment if sentiment else "neutral"

    async def generate_followup(
        self,
        sender: dict[str, Any],
        prospect: dict[str, Any],
        voice: dict[str, Any],
        campaign_context: dict[str, Any],
        conversation_history: list[dict[str, Any]],
        followup_number: int = 1,
    ) -> dict[str, Any]:
        """Generate a follow-up DM via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-followup"
        payload = {
            "sender": sender,
            "prospect": prospect,
            "voice": voice,
            "campaign_context": campaign_context,
            "conversation_history": conversation_history,
            "followup_number": followup_number,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return {
            "message": data.get("message", ""),
            "reasoning": data.get("reasoning", {}),
        }

    async def generate_reply(
        self,
        sender: dict[str, Any],
        prospect: dict[str, Any],
        voice: dict[str, Any],
        campaign_context: dict[str, Any],
        conversation_history: list[dict[str, Any]],
        reply_text: str,
        sentiment: str,
        booking_link: str = "",
    ) -> dict[str, Any]:
        """Generate a reply via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-reply"
        payload = {
            "sender": sender,
            "prospect": prospect,
            "voice": voice,
            "campaign_context": campaign_context,
            "conversation_history": conversation_history,
            "reply_text": reply_text,
            "sentiment": sentiment,
            "booking_link": booking_link,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return {
            "message": data.get("message", ""),
            "reasoning": data.get("reasoning", {}),
        }

    async def generate_comment(
        self,
        sender: dict[str, Any],
        prospect: dict[str, Any],
        voice: dict[str, Any],
        post_data: dict[str, Any],
    ) -> dict[str, Any]:
        """Generate a comment via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-comment"
        payload = {
            "sender": sender,
            "prospect": prospect,
            "voice": voice,
            "post_data": post_data,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return {
            "comment": data.get("comment", ""),
            "style": data.get("style", ""),
            "reasoning": data.get("reasoning", {}),
        }

    async def analyze_prospect(
        self,
        prospect: dict[str, Any],
        campaign_context: dict[str, Any],
        icp_data: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Analyze a prospect via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/analyze-prospect"
        payload = {
            "prospect": prospect,
            "campaign_context": campaign_context,
            "icp_data": icp_data or {},
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json()

    # ── Experiment Analysis (LLM proxy) ──

    async def analyze_experiments(self, snapshot: str) -> dict[str, Any]:
        """Analyze campaign data via backend LLM for experiment hypotheses."""
        url = f"{self.base_url}/api/v1/llm/analyze-experiments"
        payload = {"snapshot": snapshot}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json()

    # ── Strategy Engine (LLM proxy) ──

    async def analyze_strategy(self, snapshot: str) -> dict[str, Any]:
        """Analyze cross-campaign data via backend LLM for strategy patterns."""
        url = f"{self.base_url}/api/v1/llm/analyze-strategy"
        payload = {"snapshot": snapshot}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json()

    # ── Communication Strategist (LLM proxy) ──

    async def plan_daily_actions(self, prompt: str) -> dict[str, Any]:
        """Generate daily action plans for a batch of prospects via backend LLM."""
        url = f"{self.base_url}/api/v1/llm/plan-daily-actions"
        payload = {"prompt": prompt}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        return resp.json()

    # ── Brand Strategy (LLM proxy) ──

    async def analyze_brand_strategy(
        self,
        profile: dict[str, Any],
        posts: list[dict],
        ssi_data: dict[str, Any],
        campaign_stats: dict[str, Any],
        icp_context: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Analyze brand strategy via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/analyze-brand"
        payload = {
            "profile": profile,
            "posts": posts,
            "ssi_data": ssi_data,
            "campaign_stats": campaign_stats,
        }
        if icp_context:
            payload["icp_context"] = icp_context
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return data.get("analysis", data)

    async def brand_generate(
        self,
        prompt: str,
        system: str = "",
        temperature: float = 0.7,
        max_tokens: int = 4000,
    ) -> str:
        """Generic brand LLM call via backend Gemini API.

        Sends a pre-built prompt to the backend, returns raw LLM text.
        Used by generate_brand_plan/action to avoid needing a local API key.
        """
        url = f"{self.base_url}/api/v1/llm/brand-generate"
        payload = {
            "prompt": prompt,
            "system": system,
            "temperature": temperature,
            "max_tokens": max_tokens,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code in (502, 503):
            raise UnipileError(f"Backend LLM error: {resp.text[:200]}")
        resp.raise_for_status()
        return resp.json().get("text", "")

    # ── Profile Editing (proxy to Unipile via backend) ──

    async def update_profile_headline(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002 — deprecated, kept for backward compat
        new_headline: str,
    ) -> dict[str, Any]:
        """Update LinkedIn profile headline via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"headline": new_headline}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_profile_summary(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002 — deprecated, kept for backward compat
        new_summary: str,
    ) -> dict[str, Any]:
        """Update LinkedIn profile summary via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"summary": new_summary}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def upload_profile_photo(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002 — deprecated, kept for backward compat
        image_bytes: bytes,
        content_type: str = "image/jpeg",
    ) -> dict[str, Any]:
        """Upload profile photo via backend → Unipile edit endpoint."""
        import base64

        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {
            "image_base64": base64.b64encode(image_bytes).decode(),
            "image_content_type": content_type,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def upload_cover_photo(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        image_bytes: bytes,
        content_type: str = "image/jpeg",
    ) -> dict[str, Any]:
        """Upload cover/banner photo via backend → Unipile edit endpoint."""
        import base64

        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {
            "cover_image_base64": base64.b64encode(image_bytes).decode(),
            "cover_image_content_type": content_type,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_custom_link(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        category: str,
        url_value: str,
    ) -> dict[str, Any]:
        """Set a custom profile link via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"custom_link": {"category": category, "url": url_value}}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_location(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        location_id: str,
    ) -> dict[str, Any]:
        """Update profile location via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"location_id": location_id}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_skills(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        skills: list[str],
    ) -> dict[str, Any]:
        """Add skills via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"skills": skills}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_skills_follow(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        skills_follow: bool,
    ) -> dict[str, Any]:
        """Enable/disable skills follow via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"skills_follow": skills_follow}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_experience(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        experience: dict,
    ) -> dict[str, Any]:
        """Add/edit experience entry via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"experience": experience}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_education(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        education: dict,
    ) -> dict[str, Any]:
        """Add/edit education entry via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"education": education}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_picture_settings(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        settings: dict,
    ) -> dict[str, Any]:
        """Update profile picture settings via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"picture_settings": settings}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_cover_picture_settings(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        settings: dict,
    ) -> dict[str, Any]:
        """Update cover picture settings via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"cover_picture_settings": settings}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    async def update_open_to_work(
        self,
        account_id: str,
        provider_id: str,  # noqa: ARG002
        settings: dict,
    ) -> dict[str, Any]:
        """Update Open to Work settings via backend → Unipile edit endpoint."""
        url = f"{self.base_url}/api/v1/linkedin/edit-profile"
        payload = {"open_to_work": settings}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            if resp.status_code in (200, 201):
                data = resp.json()
                return {"success": data.get("success", True), "error": ""}
            return {"success": False, "error": f"Backend returned {resp.status_code}: {resp.text[:200]}"}
        except Exception as e:
            return {"success": False, "error": f"Backend request failed: {e}"}

    # ── Filter Candidates (LLM proxy for enrichment) ──

    async def filter_candidates(self, prompt: str) -> str | None:
        """Run a filter_candidates prompt through the backend LLM proxy.

        Used by linkedin_enricher when no local LLM key is available.
        Returns raw LLM response text (JSON) or None on failure.
        """
        url = f"{self.base_url}/api/v1/llm/filter-candidates"
        payload = {"prompt": prompt}
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            logger.debug(f"filter_candidates connection error: {e}")
            return None
        if resp.status_code == 503:
            logger.debug("Backend GEMINI_API_KEY not configured — filter_candidates unavailable")
            return None
        if resp.status_code == 401:
            logger.debug("Backend JWT expired for filter_candidates")
            return None
        if resp.status_code != 200:
            logger.debug(f"filter_candidates failed: HTTP {resp.status_code}")
            return None
        data = resp.json()
        return data.get("text") or None

    # ── News Search Proxy ──

    async def search_news(
        self,
        query: str,
        num_results: int = 3,
        time_range: str = "pastMonth",
    ) -> list[dict[str, Any]]:
        """Search for news articles via the backend SERPER proxy.

        Args:
            query: Search query string.
            num_results: Number of results to return (1-10).
            time_range: Time range filter: "pastWeek", "pastMonth", "pastYear".

        Returns:
            List of news items with title, link, snippet, date, source.
            Returns empty list on error (graceful fallback).
        """
        url = f"{self.base_url}/api/v1/news/search"
        payload = {
            "query": query,
            "num_results": num_results,
            "time_range": time_range,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            logger.warning(f"News search connection error: {e}")
            return []
        if resp.status_code == 503:
            logger.info("Backend SERPER_API_KEY not configured — no news available")
            return []
        if resp.status_code == 401:
            logger.warning("Backend JWT expired for news search")
            return []
        if resp.status_code != 200:
            logger.warning(f"News search failed: HTTP {resp.status_code}")
            return []
        data = resp.json()
        return data.get("news", [])

    async def find_chat_for_user(
        self,
        account_id: str,
        prospect_linkedin_id: str,
    ) -> str | None:
        """Find the chat_id for a conversation with a specific prospect.

        Searches through recent chats to find one where the prospect is an attendee.

        Args:
            account_id: The user's Unipile account ID.
            prospect_linkedin_id: The prospect's LinkedIn provider_id.

        Returns:
            chat_id if found, None otherwise.
        """
        url = f"{self.base_url}/api/v1/chats"
        params = {"limit": "50"}
        try:
            try:
                resp = await self._client.get(url, params=params, headers=self._headers())
            except (httpx.ConnectError, httpx.TimeoutException) as e:
                logger.warning(f"find_chat_for_user connection error: {e}")
                return None
            if resp.status_code != 200:
                return None
            data = resp.json()

            items = data.get("items") or data.get("chats") or data.get("data") or []
            if isinstance(data, list):
                items = data

            for chat in items:
                if not isinstance(chat, dict):
                    continue
                chat_id = chat.get("id") or chat.get("chat_id") or ""
                if not chat_id:
                    continue

                # Check attendee_provider_id (flat field) or attendees (list)
                att_pid = chat.get("attendee_provider_id") or ""
                if str(att_pid) == str(prospect_linkedin_id):
                    return str(chat_id)
                attendees = chat.get("attendees") or []
                for att in attendees:
                    if not isinstance(att, dict):
                        continue
                    # Match by provider_id, public_identifier, or id
                    for key in ("provider_id", "public_identifier", "id"):
                        att_id = att.get(key, "")
                        if att_id and str(att_id) == str(prospect_linkedin_id):
                            return str(chat_id)

            return None
        except Exception as e:
            logger.warning(f"find_chat_for_user failed: {e}")
            return None

    async def mark_chat_read(self, chat_id: str) -> dict[str, Any]:
        """Mark a chat as read via backend proxy."""
        url = f"{self.base_url}/api/v1/chats/{chat_id}/read"
        try:
            resp = await self._client.patch(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return resp.json() if resp.text else {"ok": True}
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            return {"ok": False, "error": str(_wrap_connection_error(e, self.base_url))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    async def archive_chat(self, chat_id: str) -> dict[str, Any]:
        """Archive a chat via backend proxy."""
        url = f"{self.base_url}/api/v1/chats/{chat_id}/archive"
        try:
            resp = await self._client.patch(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return resp.json() if resp.text else {"ok": True}
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            return {"ok": False, "error": str(_wrap_connection_error(e, self.base_url))}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    async def reconnect_account(self, account_id: str) -> dict[str, Any]:
        """Reconnect a disconnected account via backend proxy."""
        url = f"{self.base_url}/api/v1/accounts/{account_id}/reconnect"
        try:
            resp = await self._client.post(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            raise UnipileError(f"Reconnect failed: {e}") from e

    async def handle_checkpoint(self, account_id: str, code: str = "") -> dict[str, Any]:
        """Handle 2FA/checkpoint challenge via backend proxy."""
        url = f"{self.base_url}/api/v1/accounts/{account_id}/checkpoint"
        body: dict[str, Any] = {}
        if code:
            body["code"] = code
        try:
            resp = await self._client.post(url, headers=self._headers(), json=body)
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            raise UnipileError(f"Checkpoint failed: {e}") from e

    async def resync_account(self, account_id: str) -> dict[str, Any]:
        """Trigger account data resync via backend proxy."""
        url = f"{self.base_url}/api/v1/accounts/{account_id}/resync"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            raise UnipileError(f"Resync failed: {e}") from e

    async def create_post(self, account_id: str, text: str) -> dict[str, Any]:
        """Create a LinkedIn post via backend proxy."""
        url = f"{self.base_url}/api/v1/posts"
        try:
            resp = await self._client.post(
                url, json={"text": text}, headers=self._headers(),
            )
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            data = resp.json()
            return {"success": True, "post_id": data.get("id") or data.get("post_id") or "", "error": ""}
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            return {"success": False, "error": str(e), "post_id": ""}

    async def get_post_comments(
        self, account_id: str, post_id: str, limit: int = 20,
    ) -> list[dict[str, Any]]:
        """Get comments on a LinkedIn post via backend proxy."""
        # Strip URN prefix — send raw numeric ID in URL path (backend adds URN for Unipile)
        if post_id and post_id.startswith("urn:li:activity:"):
            post_id = post_id.split(":")[-1]
        url = f"{self.base_url}/api/v1/posts/{post_id}/comments?limit={limit}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            data = resp.json()
            return data if isinstance(data, list) else data.get("items", [])
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            logger.warning("get_post_comments failed: %s", e)
            return []

    async def get_post_reactions(
        self, account_id: str, post_id: str, limit: int = 100,
    ) -> list[dict[str, Any]]:
        """Get reactions on a LinkedIn post via backend proxy."""
        # Strip URN prefix — send raw numeric ID in URL path (backend adds URN for Unipile)
        if post_id and post_id.startswith("urn:li:activity:"):
            post_id = post_id.split(":")[-1]
        url = f"{self.base_url}/api/v1/posts/{post_id}/reactions?limit={limit}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            data = resp.json()
            items = data if isinstance(data, list) else data.get("items", [])
            reactions: list[dict[str, Any]] = []
            for item in items:
                if not isinstance(item, dict):
                    continue
                author = item.get("author") or {}
                if isinstance(author, str):
                    author = {"name": author}
                reactions.append({
                    "author_id": str(author.get("provider_id") or author.get("id") or item.get("provider_id") or ""),
                    "author_name": author.get("name") or item.get("name") or "",
                    "author_headline": author.get("headline") or "",
                    "reaction_type": item.get("reaction_type") or item.get("type") or "",
                    "timestamp": item.get("timestamp") or item.get("created_at") or "",
                })
            return reactions
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            logger.warning("get_post_reactions failed: %s", e)
            return []

    async def qualify_inbound(
        self,
        profile: dict[str, Any],
        content: str,
        signal_type: str,
        icp_summaries: list[dict[str, Any]],
    ) -> Any:
        """Qualify an inbound signal via the backend LLM proxy."""
        from ..ai.inbound_qualifier import InboundQualification

        url = f"{self.base_url}/api/v1/llm/qualify-inbound"
        payload = {
            "profile": profile,
            "content": content,
            "signal_type": signal_type,
            "icp_summaries": icp_summaries,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return InboundQualification(
            intent=data.get("intent", "unknown"),
            matched_icp_id=data.get("matched_icp_id"),
            confidence=float(data.get("confidence", 0.3)),
            recommended_action=data.get("recommended_action", "ask_purpose"),
            reasoning=data.get("reasoning", ""),
        )

    async def generate_discovery_dm(
        self,
        profile: dict[str, Any],
        signal_type: str,
        content: str,
        voice: dict[str, Any],
        qualification: dict[str, Any] | None = None,
        sender_headline: str = "",
    ) -> dict[str, str]:
        """Generate a discovery DM via the backend LLM proxy."""
        url = f"{self.base_url}/api/v1/llm/generate-discovery"
        payload = {
            "profile": profile,
            "signal_type": signal_type,
            "content": content,
            "voice": voice,
            "qualification": qualification or {},
            "sender_headline": sender_headline,
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return {
            "message": data.get("message", ""),
            "reasoning": data.get("reasoning", ""),
        }

    async def classify_signal(
        self,
        content: str,
        author_name: str = "",
        author_headline: str = "",
        author_company: str = "",
        icp_summaries: list[dict[str, Any]] | None = None,
    ) -> Any:
        """Classify a signal's intent via the backend LLM proxy."""
        from ..ai.signal_classifier import SignalClassification

        url = f"{self.base_url}/api/v1/llm/classify-signal"
        payload = {
            "content": content,
            "author_name": author_name,
            "author_headline": author_headline,
            "author_company": author_company,
            "icp_summaries": icp_summaries or [],
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return SignalClassification(
            intent=data.get("intent", "unknown"),
            confidence=float(data.get("confidence", 0.3)),
            pain_points_detected=data.get("pain_points_detected", []),
            keywords_matched=data.get("keywords_matched", []),
            engagement_hook=data.get("engagement_hook", ""),
            reasoning=data.get("reasoning", ""),
        )

    async def analyze_post(
        self,
        post_text: str,
        author_info: dict[str, Any] | None = None,
    ) -> Any:
        """Analyze a post's topic/sentiment via the backend LLM proxy."""
        from ..ai.post_analyzer import PostAnalysis

        url = f"{self.base_url}/api/v1/llm/analyze-post"
        payload = {
            "post_text": post_text,
            "author_info": author_info or {},
        }
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url)
        if resp.status_code == 401:
            raise UnipileAuthError("Backend JWT expired or invalid.")
        if resp.status_code == 503:
            raise UnipileError("Backend LLM service unavailable.")
        resp.raise_for_status()
        data = resp.json()
        return PostAnalysis(
            topic=data.get("topic", "unknown"),
            subtopics=data.get("subtopics", []),
            sentiment=data.get("sentiment", "neutral"),
            key_themes=data.get("key_themes", []),
            pain_points=data.get("pain_points", []),
            buying_signals=data.get("buying_signals", []),
            engagement_hook=data.get("engagement_hook", ""),
        )

    async def reply_to_comment(
        self, account_id: str, post_id: str, comment_id: str, text: str,
    ) -> dict[str, Any]:
        """Reply to a comment on a LinkedIn post via backend proxy."""
        url = f"{self.base_url}/api/v1/posts/{post_id}/comments"
        try:
            resp = await self._client.post(
                url,
                json={"text": text, "parent_comment_id": comment_id},
                headers=self._headers(),
            )
            if resp.status_code == 429:
                _raise_rate_limit(resp)
            resp.raise_for_status()
            return {"success": True, "error": ""}
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
        except UnipileError:
            raise
        except Exception as e:
            return {"success": False, "error": str(e)}

    # ── Network Intelligence Layer ──

    async def network_pool_opt_in(self, account_id: str = "") -> dict[str, Any]:
        """Opt the user's LinkedIn account into the network pool."""
        url = f"{self.base_url}/api/v1/network/pool/opt-in"
        try:
            resp = await self._client.post(
                url, json={"account_id": account_id}, headers=self._headers(),
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_pool_opt_out(self) -> dict[str, Any]:
        """Opt the user's LinkedIn account out of the network pool."""
        url = f"{self.base_url}/api/v1/network/pool/opt-out"
        try:
            resp = await self._client.post(url, json={}, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_pool_status(self) -> dict[str, Any]:
        """Get network pool health dashboard."""
        url = f"{self.base_url}/api/v1/network/pool/status"
        try:
            resp = await self._client.get(url, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_pool_sync(self) -> dict[str, Any]:
        """Trigger connection graph sync for the user's pool account."""
        url = f"{self.base_url}/api/v1/network/pool/sync"
        try:
            resp = await self._client.post(url, json={}, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_pool_opt_in_all(self) -> dict[str, Any]:
        """Admin: opt in all connected LinkedIn accounts."""
        url = f"{self.base_url}/api/v1/network/pool/opt-in-all"
        try:
            resp = await self._client.post(url, json={}, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_pool_sync_all(self) -> dict[str, Any]:
        """Admin: sync connections for all pool accounts."""
        url = f"{self.base_url}/api/v1/network/pool/sync-all"
        try:
            resp = await self._client.post(
                url, json={}, headers=self._headers(),
                timeout=httpx.Timeout(300.0),  # long timeout for full sync
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_enrich(
        self, linkedin_id: str, force_refresh: bool = False,
    ) -> dict[str, Any]:
        """Smart single-profile enrichment via closest-connected account."""
        url = f"{self.base_url}/api/v1/network/enrich"
        try:
            resp = await self._client.post(
                url,
                json={"linkedin_id": linkedin_id, "force_refresh": force_refresh},
                headers=self._headers(),
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_contact_info(self, linkedin_id: str) -> dict[str, Any]:
        """Get email/phone via 1st-degree connected pool account."""
        url = f"{self.base_url}/api/v1/network/contact-info"
        try:
            resp = await self._client.post(
                url, json={"linkedin_id": linkedin_id}, headers=self._headers(),
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_parallel_enrich(
        self, linkedin_ids: list[str], max_concurrency: int = 10,
    ) -> dict[str, Any]:
        """Fan-out profile enrichment across pool accounts."""
        url = f"{self.base_url}/api/v1/network/parallel-enrich"
        try:
            resp = await self._client.post(
                url,
                json={"linkedin_ids": linkedin_ids, "max_concurrency": max_concurrency},
                headers=self._headers(),
                timeout=httpx.Timeout(300.0),  # long timeout for 100 profiles
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_search(
        self,
        keywords: str = "",
        title: str = "",
        max_accounts: int = 5,
        count_per_account: int = 25,
    ) -> dict[str, Any]:
        """Distributed search across pool accounts."""
        url = f"{self.base_url}/api/v1/network/search"
        try:
            resp = await self._client.post(
                url,
                json={
                    "keywords": keywords,
                    "title": title,
                    "max_accounts": max_accounts,
                    "count_per_account": count_per_account,
                },
                headers=self._headers(),
                timeout=httpx.Timeout(120.0),
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_reach(self, linkedin_id: str) -> dict[str, Any]:
        """Show which pool accounts can reach a prospect."""
        url = f"{self.base_url}/api/v1/network/reach/{linkedin_id}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_intros(self, linkedin_id: str) -> dict[str, Any]:
        """Find warm introduction paths to a prospect."""
        url = f"{self.base_url}/api/v1/network/intros/{linkedin_id}"
        try:
            resp = await self._client.get(url, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    # ── Network Message Insights ──

    async def network_insights_query(
        self,
        insight_type: str = "",
        segment: str = "",
        min_confidence: float = 0.0,
        limit: int = 50,
    ) -> dict[str, Any]:
        """Query aggregated message insights."""
        url = f"{self.base_url}/api/v1/network/insights/query"
        payload: dict[str, Any] = {"limit": limit}
        if insight_type:
            payload["insight_type"] = insight_type
        if segment:
            payload["segment"] = segment
        if min_confidence > 0:
            payload["min_confidence"] = min_confidence
        try:
            resp = await self._client.post(url, json=payload, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_insights_summary(self) -> dict[str, Any]:
        """Get insight dashboard summary."""
        url = f"{self.base_url}/api/v1/network/insights/summary"
        try:
            resp = await self._client.get(url, headers=self._headers())
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e

    async def network_insights_refresh(self) -> dict[str, Any]:
        """Admin: trigger manual insight extraction cycle."""
        url = f"{self.base_url}/api/v1/network/insights/refresh"
        try:
            resp = await self._client.post(
                url, json={}, headers=self._headers(), timeout=300.0,
            )
            resp.raise_for_status()
            return resp.json()
        except (httpx.ConnectError, httpx.TimeoutException) as e:
            raise _wrap_connection_error(e, self.base_url) from e
