"""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 .unipile import UnipileAuthError, UnipileError, _is_newsletter_invitation

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=60.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}")


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]:
        return {
            "Authorization": f"Bearer {self.jwt_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    async def close(self) -> None:
        await self._client.aclose()

    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.
        """
        last_error: Exception | None = None
        for attempt in range(_MAX_RETRIES):
            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())
                if resp.status_code == 429:
                    _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:
                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))

        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,
            "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,
    ) -> tuple[list[dict[str, Any]], str | None]:
        """Search LinkedIn for people via the backend proxy with structured filters."""
        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

        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"
        try:
            resp = await self._client.get(url, 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 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/profile/{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()
            status_code = data.get("status_code", 200)

            if status_code in (200, 201):
                result["success"] = True
            elif status_code == 429:
                result["error"] = "Rate limited by LinkedIn. Will retry later."
                result["blocked"] = True
            elif status_code in (401, 403):
                result["error"] = "LinkedIn account disconnected."
                result["auth_error"] = True
                result["blocked"] = True
            elif status_code == 409:
                result["error"] = "Already connected or invitation pending."
            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))
            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."""
        url = f"{self.base_url}/api/v1/chats"
        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("chats") or data.get("data") or []
            if isinstance(data, list):
                items = data

            # Normalize — same logic as UnipileClient.get_chats
            messages: list[dict[str, Any]] = []
            for chat in items:
                if not isinstance(chat, dict):
                    continue
                chat_id = chat.get("id") or chat.get("chat_id") or ""
                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:
                    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", "")

                if not sender_name:
                    attendees = chat.get("attendees") or []
                    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 ""

                ts_raw = last_msg.get("timestamp") or last_msg.get("created_at") or last_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_name": sender_name,
                    "sender_id": str(sender_id),
                    "text": text,
                    "timestamp": timestamp,
                    "conversation_urn": str(chat_id),
                })
            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)
            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"Send failed: {e}"
        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)
            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
                result["chat_id"] = data.get("chat_id") or data.get("id") or ""
            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"Send new message failed: {e}"
        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()
            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"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()
            status_code = data.get("status_code", 200)
            if status_code in (200, 201):
                result["success"] = True
                result["chat_id"] = data.get("chat_id") or ""
            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"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._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("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
                messages.append({
                    "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 []

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

    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 ""
                metrics = item.get("metrics") or item.get("social_counts") or {}
                posts.append({
                    "id": str(post_id),
                    "text": str(text),
                    "date": str(post_date),
                    "metrics": metrics if isinstance(metrics, dict) else {},
                })
            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,
    ) -> 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 = {"post_id": post_id, "text": text}
        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, 202):
                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"Comment failed: {e}"
        return result

    async def send_post_reaction(
        self,
        account_id: str,
        post_id: str,
        reaction_type: str = "LIKE",
    ) -> 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 = {"post_id": post_id, "reaction_type": reaction_type}
        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, 202):
                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"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()

            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_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 ""
                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
                invitations.append({
                    "id": str(inv_id),
                    "sender_name": sender_name,
                    "sender_id": str(sender_id),
                    "headline": headline,
                    "message": message,
                    "timestamp": timestamp,
                })
            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",
    ) -> 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".

        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 = {"action": action}
        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

    # ── 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}
        """
        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:
                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"Follow 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.

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

        Returns:
            List of viewer dicts with name, title, company, relation, url.
        """
        url = f"{self.base_url}/api/v1/linkedin/profile-viewers"
        try:
            resp = await self._client.get(url, headers=self._headers())
            if resp.status_code != 200:
                return []
            data = resp.json()
            return data.get("viewers", [])
        except Exception:
            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("likes") or 0,
                    "comments_count": item.get("comments_count") or item.get("comments") or 0,
                    "timestamp": item.get("timestamp") or item.get("created_at") or "",
                })
            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."""
        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:
                return {}
            data = resp.json()
            return data
        except Exception:
            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,
    ) -> 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.

        Returns:
            List of {name, code} dicts.
        """
        url = f"{self.base_url}/api/v1/search/parameters"
        payload = {"type": type, "keywords": keywords}
        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:
            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],
    ) -> 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,
        }
        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()

    # ── 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,
        new_headline: str,
    ) -> dict[str, Any]:
        """Update LinkedIn profile headline via backend → Unipile raw API."""
        url = f"{self.base_url}/api/v1/linkedin/update-profile"
        payload = {
            "account_id": account_id,
            "provider_id": provider_id,
            "field": "headline",
            "value": 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,
        new_summary: str,
    ) -> dict[str, Any]:
        """Update LinkedIn profile summary via backend → Unipile raw API."""
        url = f"{self.base_url}/api/v1/linkedin/update-profile"
        payload = {
            "account_id": account_id,
            "provider_id": provider_id,
            "field": "summary",
            "value": 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,
        image_bytes: bytes,
        content_type: str = "image/jpeg",
    ) -> dict[str, Any]:
        """Upload profile photo via backend → Unipile raw API."""
        import base64

        url = f"{self.base_url}/api/v1/linkedin/upload-photo"
        payload = {
            "account_id": account_id,
            "provider_id": provider_id,
            "image_base64": base64.b64encode(image_bytes).decode(),
            "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}"}

    # ── 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 attendees for the prospect
                attendees = chat.get("attendees") or []
                for att in attendees:
                    if not isinstance(att, dict):
                        continue
                    att_id = att.get("provider_id") or att.get("id") or ""
                    if 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."""
        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 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,
    ) -> 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 {},
        }
        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 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)}
