"""
Apify API helper utilities for g-gremlin.

Provides company enrichment via Apify actors:
- apioracle/company-domain: Company name -> website + social links
- s-r/free-linkedin-company-finder: Domain -> LinkedIn company URL

Person discovery via LinkedIn search actors:
- anchor/linkedin-people-finder: Find people by company + role keywords
- harvestapi/linkedin-profile-search: Profile search with filters
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import re
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse

from g_gremlin.base import AuthenticationError, BaseIntegration, IntegrationError, RateLimitError, RetryConfig
from g_gremlin.credentials import resolve_credentials
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError

APIFY_CONFIG_PATH = Path.home() / ".g_gremlin" / "apify.json"
APIFY_BASE_URL = "https://api.apify.com/v2"

# Default actor for company -> domain enrichment
DEFAULT_COMPANY_ACTOR = "apioracle/company-domain"
DEFAULT_LINKEDIN_ACTOR = "s-r/free-linkedin-company-finder---linkedin-address-from-any-site"

# Person discovery actors (LinkedIn people search)
# harvestapi/linkedin-profile-search: Direct LinkedIn search (no cookies, FREE)
# anchor/linkedin-people-finder: Google-based LinkedIn profile finder (paid)
DEFAULT_PEOPLE_FINDER_ACTOR = "harvestapi/linkedin-profile-search"
ALT_PEOPLE_SEARCH_ACTOR = "anchor/linkedin-people-finder"

# Safety caps
DEFAULT_TIMEOUT_SECS = 120
DEFAULT_WAIT_SECS = 300
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 5
DEFAULT_CONCURRENCY = 15
DEFAULT_PEOPLE_CONCURRENCY = 5  # Lower concurrency for people search (rate limits)
DEFAULT_RETRY_CONFIG = RetryConfig(max_attempts=4, base_delay=1.0, max_delay=12.0)

# In-memory caches for 2-step pipeline (cleared on module reload)
_COMPANY_LINKEDIN_CACHE: dict[str, str] = {}  # company_name (raw-lc) -> linkedin_url
_PERSON_DISCOVERY_CACHE: dict[str, dict] = {}  # "company|title" -> result


class ApifyError(RuntimeError):
    """Raised when Apify operations fail."""


class ApifyAuthError(ApifyError):
    """Raised when Apify authentication fails."""


@dataclass
class ApifyConfig:
    """Apify API configuration."""
    api_token: str


def save_apify_config(config: ApifyConfig) -> None:
    """Persist Apify configuration to disk."""
    APIFY_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    APIFY_CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")


def load_apify_config() -> ApifyConfig:
    """Load Apify configuration from disk."""
    if not APIFY_CONFIG_PATH.exists():
        raise ApifyError(
            "Apify is not configured. Run 'g-gremlin apify connect' first.\n"
            "Get your API token from: https://console.apify.com/settings/integrations"
        )
    try:
        data = json.loads(APIFY_CONFIG_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise ApifyError(f"Invalid Apify config at {APIFY_CONFIG_PATH}: {exc}") from exc

    api_token = (data.get("api_token") or "").strip()
    if not api_token:
        raise ApifyError(
            f"Missing api_token in {APIFY_CONFIG_PATH}. "
            "Re-run 'g-gremlin apify connect --api-token YOUR_TOKEN'."
        )
    return ApifyConfig(api_token=api_token)


def get_apify_token(credential_provider: CredentialProvider | None = None) -> str:
    provider = credential_provider or CredentialProvider()
    try:
        creds = resolve_credentials(
            "apify",
            required_keys=["api_token"],
            credential_provider=provider,
        )
        return creds["api_token"]
    except SecretNotFoundError as exc:
        raise ApifyError(
            "Apify not configured. Set G_GREMLIN_APIFY_API_TOKEN or run g-gremlin apify connect."
        ) from exc


class ApifyClient(BaseIntegration):
    """HTTP Apify client using BaseIntegration for retries and auth."""

    SERVICE_NAME = "apify"

    def __init__(
        self,
        api_token: Optional[str] = None,
        credential_provider: CredentialProvider | None = None,
        retry_config: RetryConfig | None = None,
    ) -> None:
        super().__init__(credential_provider=credential_provider, default_retry_config=retry_config or DEFAULT_RETRY_CONFIG)
        self.api_token = api_token or get_apify_token(credential_provider)

    def _format_actor_id(self, actor_id: str) -> str:
        return actor_id.replace("/", "~")

    def _run_actor_sync(
        self,
        actor_id: str,
        run_input: dict,
        timeout_secs: int,
        wait_secs: int,
    ) -> tuple[dict, list]:
        actor_slug = self._format_actor_id(actor_id)
        params = {
            "token": self.api_token,
            "timeout": timeout_secs,
            "waitForFinish": wait_secs,
        }
        url = f"{APIFY_BASE_URL}/acts/{actor_slug}/runs"

        try:
            resp = self._post(
                url,
                params=params,
                json=run_input,
                headers={"Content-Type": "application/json"},
            )
        except AuthenticationError as exc:
            raise ApifyAuthError(str(exc)) from exc
        except (RateLimitError, IntegrationError) as exc:
            raise ApifyError(str(exc)) from exc

        payload = resp.json() if resp.content else {}
        run = payload.get("data") if isinstance(payload, dict) else payload
        run = run if isinstance(run, dict) else {}

        dataset_id = run.get("defaultDatasetId") or run.get("default_dataset_id")
        items: list = []
        if dataset_id:
            items_url = f"{APIFY_BASE_URL}/datasets/{dataset_id}/items"
            try:
                items_resp = self._get(items_url, params={"token": self.api_token})
            except AuthenticationError as exc:
                raise ApifyAuthError(str(exc)) from exc
            except (RateLimitError, IntegrationError) as exc:
                raise ApifyError(str(exc)) from exc

            if items_resp.content:
                items_payload = items_resp.json()
                if isinstance(items_payload, dict):
                    items = items_payload.get("items") or []
                elif isinstance(items_payload, list):
                    items = items_payload
        return run, items


def get_apify_client():
    """Get an Apify SDK client instance with configured credentials."""
    try:
        from apify_client import ApifyClient as ApifySdkClient
    except ImportError:
        raise ApifyError("apify-client not installed. Run: pip install apify-client")

    config = load_apify_config()
    return ApifySdkClient(config.api_token)


def make_source_record_id(row_index: int, company_raw: str) -> str:
    """
    Create deterministic join key from row position + original company name.
    This survives any transformations Apify makes to the name.
    """
    normalized = (company_raw or "").strip().lower()
    content = f"{row_index}:{normalized}"
    return f"row_{row_index:04d}_{hashlib.md5(content.encode()).hexdigest()[:8]}"


def normalize_company_name(name: str) -> str:
    """Strip common company suffixes for cleaner matching."""
    suffixes = r",?\s*(Inc\.?|LLC|Ltd\.?|Corp\.?|Corporation|Company|Co\.?)$"
    return re.sub(suffixes, "", name.strip(), flags=re.IGNORECASE).strip()


def extract_domain(url: str) -> str:
    """Extract domain from URL, stripping protocol and www prefix."""
    if not url:
        return ""
    parsed = urlparse(url if "://" in url else f"https://{url}")
    return parsed.netloc.lower().replace("www.", "")


def extract_social_link(social_links: list, pattern: str) -> str:
    """Extract first social link matching pattern from array."""
    if not social_links:
        return ""
    for link in social_links:
        if isinstance(link, str) and pattern in link.lower():
            return link
    return ""


def enrich_company_sync(
    company: str,
    country: str = "US",
    actor_id: str = DEFAULT_COMPANY_ACTOR,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
    max_retries: int = DEFAULT_MAX_RETRIES,
    retry_delay: int = DEFAULT_RETRY_DELAY,
) -> dict[str, Any]:
    """
    Run single company enrichment synchronously with retries.

    Returns dict with:
        - success: bool
        - data: dict with official_website, social_links, etc.
        - run_id: Apify run ID
        - dataset_id: Apify dataset ID
        - error: error message if failed
    """
    client = ApifyClient()

    for attempt in range(max_retries):
        try:
            run, items = client._run_actor_sync(
                actor_id=actor_id,
                run_input={"name": company, "country": country},
                timeout_secs=timeout_secs,
                wait_secs=wait_secs,
            )
            return {
                "success": True,
                "data": items[0] if items else {},
                "run_id": run.get("id"),
                "dataset_id": run.get("defaultDatasetId"),
                "error": None
            }
        except ApifyAuthError as exc:
            raise exc
        except Exception as e:
            error_str = str(e).lower()
            # Don't retry auth/input errors
            if "401" in error_str:
                raise ApifyAuthError(str(e)) from e
            if any(x in error_str for x in ["404", "invalid"]):
                return {"success": False, "error": str(e), "data": {}, "run_id": None, "dataset_id": None}

            if attempt < max_retries - 1:
                delay = retry_delay * (2 ** attempt)
                time.sleep(delay)
            else:
                return {"success": False, "error": str(e), "data": {}, "run_id": None, "dataset_id": None}

    return {"success": False, "error": "Max retries exceeded", "data": {}, "run_id": None, "dataset_id": None}


async def enrich_companies_async(
    companies: list[dict],
    company_key: str = "name",
    country: str = "US",
    actor_id: str = DEFAULT_COMPANY_ACTOR,
    concurrency: int = DEFAULT_CONCURRENCY,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
) -> list[dict]:
    """
    Enrich multiple companies concurrently using asyncio.

    Args:
        companies: List of dicts with company info
        company_key: Key in each dict containing company name
        country: Country code for search
        actor_id: Apify actor ID
        concurrency: Max concurrent enrichments (10-25 recommended)
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait

    Returns:
        List of enrichment results, sorted by original index
    """
    try:
        from apify_client import ApifyClientAsync
    except ImportError:
        raise ApifyError("apify-client not installed. Run: pip install apify-client")

    config = load_apify_config()
    client = ApifyClientAsync(config.api_token)
    semaphore = asyncio.Semaphore(concurrency)

    async def enrich_one(idx: int, company_data: dict) -> dict:
        company_name = company_data.get(company_key, "").strip()
        source_id = make_source_record_id(idx, company_name)

        if not company_name:
            return {
                "idx": idx,
                "source_record_id": source_id,
                "input": company_data,
                "output": {},
                "run_id": None,
                "dataset_id": None,
                "error": "Empty company name"
            }

        async with semaphore:
            try:
                run = await client.actor(actor_id).call(
                    run_input={"name": company_name, "country": country},
                    timeout_secs=timeout_secs,
                    wait_secs=wait_secs,
                )
                items = await client.dataset(run["defaultDatasetId"]).list_items()
                data = items.items[0] if items.items else {}
                return {
                    "idx": idx,
                    "source_record_id": source_id,
                    "input": company_data,
                    "output": data,
                    "run_id": run["id"],
                    "dataset_id": run["defaultDatasetId"],
                    "error": None
                }
            except Exception as e:
                return {
                    "idx": idx,
                    "source_record_id": source_id,
                    "input": company_data,
                    "output": {},
                    "run_id": None,
                    "dataset_id": None,
                    "error": str(e)
                }

    tasks = [enrich_one(i, c) for i, c in enumerate(companies)]
    results = await asyncio.gather(*tasks)

    # Sort by original index
    return sorted(results, key=lambda x: x["idx"])


def normalize_enrichment_result(
    source_id: str,
    company_raw: str,
    data: dict,
    run_id: Optional[str] = None,
    dataset_id: Optional[str] = None,
    error: Optional[str] = None,
    actor_id: str = DEFAULT_COMPANY_ACTOR,
) -> dict:
    """
    Normalize Apify enrichment result to standard schema.

    Expected actor output fields:
        - official_website: Company website URL
        - social_links: Array of social media URLs
    """
    website = data.get("official_website", "") or data.get("website", "") or ""
    social_links = data.get("social_links", []) or []

    return {
        "source_record_id": source_id,
        "company_raw": company_raw,
        "company_norm": normalize_company_name(company_raw),
        "website": website,
        "domain": extract_domain(website),
        "linkedin_url": extract_social_link(social_links, "linkedin.com/company/"),
        "twitter_url": extract_social_link(social_links, "twitter.com/") or extract_social_link(social_links, "x.com/"),
        "confidence_or_signal": 1.0 if website else 0.0,
        "apify_actor_id": actor_id,
        "apify_run_id": run_id or "",
        "apify_dataset_id": dataset_id or "",
        "error": error or ""
    }


# ============================================================================
# Person Discovery (LinkedIn People Search)
# ============================================================================


@dataclass
class PersonDiscoveryResult:
    """Result from person discovery search."""

    first_name: str
    last_name: str
    full_name: str
    title: str
    company: str
    linkedin_url: str
    location: str
    headline: str
    confidence: float
    source_row_idx: int
    error: Optional[str] = None


def _company_cache_key(company: str) -> str:
    """Cache key for company-specific lookups (raw, lowercased, trimmed)."""
    return company.strip().lower()


def _build_people_search_query(company: str, title: str) -> str:
    """
    Build search query for LinkedIn people finder.

    Strategy: "[Title] at [Company]" or "[Title] [Company]"
    """
    company_clean = normalize_company_name(company)
    title_clean = title.strip()

    # Build query: "VP Sales at Acme Inc" style
    if company_clean and title_clean:
        return f"{title_clean} at {company_clean}"
    elif company_clean:
        return company_clean
    elif title_clean:
        return title_clean
    return ""


def _extract_name_parts(full_name: str) -> tuple[str, str]:
    """Extract first and last name from full name string."""
    if not full_name:
        return "", ""

    parts = full_name.strip().split()
    if len(parts) == 0:
        return "", ""
    elif len(parts) == 1:
        return parts[0], ""
    else:
        # First word is first name, rest is last name
        return parts[0], " ".join(parts[1:])


def _normalize_person_result(
    data: dict,
    source_idx: int,
    input_company: str,
    input_title: str,
) -> PersonDiscoveryResult:
    """
    Normalize Apify people search result to standard schema.

    Different actors return different field names, so we handle multiple formats.
    """
    # Try various field name patterns
    full_name = (
        data.get("name") or
        data.get("fullName") or
        data.get("full_name") or
        f"{data.get('firstName', '')} {data.get('lastName', '')}".strip() or
        ""
    )

    first_name = data.get("firstName") or data.get("first_name") or ""
    last_name = data.get("lastName") or data.get("last_name") or ""

    # If we have full_name but not first/last, extract them
    if full_name and not (first_name and last_name):
        first_name, last_name = _extract_name_parts(full_name)

    # Rebuild full_name if we have parts
    if not full_name and (first_name or last_name):
        full_name = f"{first_name} {last_name}".strip()

    title = (
        data.get("title") or
        data.get("jobTitle") or
        data.get("job_title") or
        data.get("position") or
        ""
    )

    company = (
        data.get("company") or
        data.get("companyName") or
        data.get("company_name") or
        data.get("organization") or
        input_company  # Fall back to input
    )

    linkedin_url = (
        data.get("linkedinUrl") or
        data.get("linkedin_url") or
        data.get("profileUrl") or
        data.get("url") or
        ""
    )

    # Location can be a string or a dict with parsed location
    location_raw = data.get("location") or data.get("geo") or ""
    if isinstance(location_raw, dict):
        location = location_raw.get("linkedinText") or location_raw.get("text") or str(location_raw)
    else:
        location = str(location_raw) if location_raw else ""

    headline = (
        data.get("headline") or
        data.get("tagline") or
        ""
    )

    # Calculate confidence based on how well result matches input
    confidence = 0.0
    if full_name:
        confidence += 0.4
    if linkedin_url and "linkedin.com/in/" in linkedin_url:
        confidence += 0.3
    if title and input_title.lower() in title.lower():
        confidence += 0.2
    if company and input_company.lower() in company.lower():
        confidence += 0.1

    return PersonDiscoveryResult(
        first_name=first_name,
        last_name=last_name,
        full_name=full_name,
        title=title,
        company=company,
        linkedin_url=linkedin_url,
        location=location,
        headline=headline,
        confidence=min(confidence, 1.0),
        source_row_idx=source_idx,
    )


def discover_person_sync(
    company: str,
    title: str,
    actor_id: str = DEFAULT_PEOPLE_FINDER_ACTOR,
    max_results: int = 3,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
) -> list[dict[str, Any]]:
    """
    Discover people at a company with a specific title using LinkedIn search.

    Args:
        company: Company name to search
        title: Job title to search for
        actor_id: Apify actor ID for people search
        max_results: Maximum number of results to return
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait

    Returns:
        List of person dicts with: first_name, last_name, full_name, title,
        company, linkedin_url, location, headline, confidence
    """
    query = _build_people_search_query(company, title)
    if not query:
        return []

    client = ApifyClient()

    try:
        # Build input based on actor type
        if "anchor" in actor_id.lower():
            # anchor/linkedin-people-finder uses 'search' param
            run_input = {
                "search": query,
                "maxResults": max_results,
            }
        elif "harvestapi" in actor_id.lower():
            # harvestapi/linkedin-profile-search uses searchQuery (singular, not array)
            run_input = {
                "searchQuery": query,
                "maxResults": max_results,
            }
        elif "caprolok" in actor_id.lower():
            # caprolok/linkedin-employees-scraper uses designation + company
            company_clean = normalize_company_name(company)
            run_input = {
                "companyUrls": [],  # Would need LinkedIn company URL
                "designation": title,
                "count": max_results,
            }
        else:
            # Generic fallback - try multiple param names
            run_input = {
                "search": query,
                "query": query,
                "searchQueries": [query],
                "keywords": query,
                "maxResults": max_results,
                "count": max_results,
            }

        run, items = client._run_actor_sync(
            actor_id=actor_id,
            run_input=run_input,
            timeout_secs=timeout_secs,
            wait_secs=wait_secs,
        )

        results = []
        for idx, item in enumerate(items[:max_results]):
            person = _normalize_person_result(item, idx, company, title)
            results.append({
                "first_name": person.first_name,
                "last_name": person.last_name,
                "full_name": person.full_name,
                "title": person.title,
                "company": person.company,
                "linkedin_url": person.linkedin_url,
                "location": person.location,
                "headline": person.headline,
                "confidence": person.confidence,
                "run_id": run.get("id"),
                "dataset_id": run.get("defaultDatasetId"),
                "error": None,
            })

        return results

    except Exception as e:
        return [{
            "first_name": "",
            "last_name": "",
            "full_name": "",
            "title": title,
            "company": company,
            "linkedin_url": "",
            "location": "",
            "headline": "",
            "confidence": 0.0,
            "run_id": None,
            "dataset_id": None,
            "error": str(e),
        }]


async def discover_people_async(
    rows: list[dict],
    company_key: str = "company",
    title_key: str = "title",
    actor_id: str = DEFAULT_PEOPLE_FINDER_ACTOR,
    max_results_per_row: int = 1,
    concurrency: int = DEFAULT_PEOPLE_CONCURRENCY,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
) -> list[dict]:
    """
    Discover people for multiple company+title pairs concurrently.

    Args:
        rows: List of dicts with company and title info
        company_key: Key in each dict containing company name
        title_key: Key in each dict containing job title
        actor_id: Apify actor ID for people search
        max_results_per_row: Max people to find per input row
        concurrency: Max concurrent searches (keep low - rate limits)
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait

    Returns:
        List of discovery results, one per input row, with best match
    """
    try:
        from apify_client import ApifyClientAsync
    except ImportError:
        raise ApifyError("apify-client not installed. Run: pip install apify-client")

    config = load_apify_config()
    client = ApifyClientAsync(config.api_token)
    semaphore = asyncio.Semaphore(concurrency)

    async def discover_one(idx: int, row: dict) -> dict:
        company = row.get(company_key, "").strip()
        title = row.get(title_key, "").strip()

        query = _build_people_search_query(company, title)

        if not query:
            return {
                "idx": idx,
                "input": row,
                "first_name": "",
                "last_name": "",
                "full_name": "",
                "title": title,
                "company": company,
                "linkedin_url": "",
                "location": "",
                "headline": "",
                "confidence": 0.0,
                "run_id": None,
                "dataset_id": None,
                "error": "Empty company and title",
            }

        async with semaphore:
            try:
                # Build input based on actor type
                if "anchor" in actor_id.lower():
                    run_input = {"search": query, "maxResults": max_results_per_row}
                elif "harvestapi" in actor_id.lower():
                    run_input = {"searchQuery": query, "maxResults": max_results_per_row}
                else:
                    run_input = {
                        "search": query,
                        "query": query,
                        "searchQueries": [query],
                        "keywords": query,
                        "maxResults": max_results_per_row,
                        "count": max_results_per_row,
                    }

                run = await client.actor(actor_id).call(
                    run_input=run_input,
                    timeout_secs=timeout_secs,
                    wait_secs=wait_secs,
                )

                items_result = await client.dataset(run["defaultDatasetId"]).list_items()
                items = items_result.items if items_result else []

                if not items:
                    return {
                        "idx": idx,
                        "input": row,
                        "first_name": "",
                        "last_name": "",
                        "full_name": "",
                        "title": title,
                        "company": company,
                        "linkedin_url": "",
                        "location": "",
                        "headline": "",
                        "confidence": 0.0,
                        "run_id": run["id"],
                        "dataset_id": run["defaultDatasetId"],
                        "error": "No results found",
                    }

                # Take best result (first one, usually highest ranked)
                best = _normalize_person_result(items[0], idx, company, title)

                return {
                    "idx": idx,
                    "input": row,
                    "first_name": best.first_name,
                    "last_name": best.last_name,
                    "full_name": best.full_name,
                    "title": best.title,
                    "company": best.company,
                    "linkedin_url": best.linkedin_url,
                    "location": best.location,
                    "headline": best.headline,
                    "confidence": best.confidence,
                    "run_id": run["id"],
                    "dataset_id": run["defaultDatasetId"],
                    "error": None,
                }

            except Exception as e:
                return {
                    "idx": idx,
                    "input": row,
                    "first_name": "",
                    "last_name": "",
                    "full_name": "",
                    "title": title,
                    "company": company,
                    "linkedin_url": "",
                    "location": "",
                    "headline": "",
                    "confidence": 0.0,
                    "run_id": None,
                    "dataset_id": None,
                    "error": str(e),
                }

    tasks = [discover_one(i, row) for i, row in enumerate(rows)]
    results = await asyncio.gather(*tasks)

    return sorted(results, key=lambda x: x["idx"])


# ============================================================================
# 2-Step Pipeline: Company URL Resolution -> Person Discovery
# ============================================================================


def _make_cache_key(company: str, title: str) -> str:
    """Create cache key for company+title pair (raw company to avoid collisions)."""
    return f"{_company_cache_key(company)}|{title.strip().lower()}"


def clear_discovery_cache() -> int:
    """Clear all in-memory caches. Returns count of items cleared."""
    count = len(_COMPANY_LINKEDIN_CACHE) + len(_PERSON_DISCOVERY_CACHE)
    _COMPANY_LINKEDIN_CACHE.clear()
    _PERSON_DISCOVERY_CACHE.clear()
    return count


def resolve_company_linkedin_url(
    company: str,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
    use_cache: bool = True,
) -> str:
    """
    Resolve company name to LinkedIn company URL.

    Uses the company enrichment actor to find the company's LinkedIn URL.
    Results are cached in-memory for the session.

    Args:
        company: Company name to resolve
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait
        use_cache: Whether to use cached results

    Returns:
        LinkedIn company URL or empty string if not found
    """
    company_key = _company_cache_key(company)
    if not company_key:
        return ""

    # Check cache
    if use_cache and company_key in _COMPANY_LINKEDIN_CACHE:
        return _COMPANY_LINKEDIN_CACHE[company_key]

    # Try company enrichment
    result = enrich_company_sync(
        company=company,
        actor_id=DEFAULT_COMPANY_ACTOR,
        timeout_secs=timeout_secs,
        wait_secs=wait_secs,
    )

    linkedin_url = ""
    if result.get("success") and result.get("data"):
        data = result["data"]
        social_links = data.get("social_links", []) or []
        linkedin_url = extract_social_link(social_links, "linkedin.com/company/")

    # Cache result (even empty string to avoid re-lookup)
    if use_cache:
        _COMPANY_LINKEDIN_CACHE[company_key] = linkedin_url

    return linkedin_url


def discover_person_two_step(
    company: str,
    title: str,
    actor_id: str = DEFAULT_PEOPLE_FINDER_ACTOR,
    max_results: int = 1,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
    use_cache: bool = True,
    resolve_company_url: bool = True,
) -> dict:
    """
    Two-step person discovery: resolve company URL first, then search.

    Step 1: Resolve company name -> LinkedIn company URL
    Step 2: Search for person using company URL for higher precision

    Falls back to name-based search if company URL not found.

    Args:
        company: Company name
        title: Job title to search for
        actor_id: Apify actor ID for people search
        max_results: Maximum results to return
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait
        use_cache: Whether to use cached results
        resolve_company_url: Whether to attempt company URL resolution

    Returns:
        Dict with person info and metadata (pipeline_used, cache_hit, etc.)
    """
    cache_key = _make_cache_key(company, title)

    # Check person discovery cache
    if use_cache and cache_key in _PERSON_DISCOVERY_CACHE:
        cached = _PERSON_DISCOVERY_CACHE[cache_key].copy()
        cached["cache_hit"] = True
        return cached

    pipeline_used = "direct"
    company_linkedin_url = ""

    # Step 1: Try to resolve company LinkedIn URL
    if resolve_company_url:
        try:
            company_linkedin_url = resolve_company_linkedin_url(
                company,
                timeout_secs=timeout_secs,
                wait_secs=wait_secs,
                use_cache=use_cache,
            )
            if company_linkedin_url:
                pipeline_used = "two_step"
        except Exception:
            # Fall through to direct search
            pass

    # Step 2: Build enhanced query with company URL if available
    if company_linkedin_url and "harvestapi" in actor_id.lower():
        # HarvestAPI can use company URL filter for better precision
        # Query: "VP Sales" with companyId filter
        query = title.strip()
        # Extract company slug from URL: linkedin.com/company/acme-inc -> acme-inc
        company_slug = ""
        if "/company/" in company_linkedin_url:
            company_slug = company_linkedin_url.split("/company/")[-1].rstrip("/")
    else:
        # Fall back to standard query
        query = _build_people_search_query(company, title)
        company_slug = ""

    if not query:
        result = {
            "first_name": "",
            "last_name": "",
            "full_name": "",
            "title": title,
            "company": company,
            "linkedin_url": "",
            "location": "",
            "headline": "",
            "confidence": 0.0,
            "error": "Empty search query",
            "pipeline_used": pipeline_used,
            "company_linkedin_url": company_linkedin_url,
            "cache_hit": False,
        }
        return result

    # Build actor input
    client = ApifyClient()

    try:
        if "harvestapi" in actor_id.lower():
            # HarvestAPI uses page-based pagination and mode for cost control
            # mode: "Short" = $0.10/page (search results only, no full profile fetch)
            # mode: "Full" = $0.10/page + $0.004/profile (default, expensive!)
            # takePages: 1 = limit to first page (~25 results)
            run_input = {
                "searchQuery": query,
                "mode": "Short",  # CRITICAL: avoid $0.004/profile charges
                "takePages": 1,   # Limit to 1 page (up to 25 results)
            }
            # Add company filter if we have it
            if company_slug:
                run_input["companyId"] = company_slug
        elif "anchor" in actor_id.lower():
            run_input = {"search": query, "maxResults": max_results}
        else:
            run_input = {
                "search": query,
                "query": query,
                "maxResults": max_results,
            }

        run, items = client._run_actor_sync(
            actor_id=actor_id,
            run_input=run_input,
            timeout_secs=timeout_secs,
            wait_secs=wait_secs,
        )

        if not items:
            result = {
                "first_name": "",
                "last_name": "",
                "full_name": "",
                "title": title,
                "company": company,
                "linkedin_url": "",
                "location": "",
                "headline": "",
                "confidence": 0.0,
                "error": "No results found",
                "pipeline_used": pipeline_used,
                "company_linkedin_url": company_linkedin_url,
                "cache_hit": False,
                "run_id": run.get("id"),
                "dataset_id": run.get("defaultDatasetId"),
            }
        else:
            person = _normalize_person_result(items[0], 0, company, title)
            result = {
                "first_name": person.first_name,
                "last_name": person.last_name,
                "full_name": person.full_name,
                "title": person.title,
                "company": person.company,
                "linkedin_url": person.linkedin_url,
                "location": person.location,
                "headline": person.headline,
                "confidence": person.confidence,
                "error": None,
                "pipeline_used": pipeline_used,
                "company_linkedin_url": company_linkedin_url,
                "cache_hit": False,
                "run_id": run.get("id"),
                "dataset_id": run.get("defaultDatasetId"),
            }

    except Exception as e:
        result = {
            "first_name": "",
            "last_name": "",
            "full_name": "",
            "title": title,
            "company": company,
            "linkedin_url": "",
            "location": "",
            "headline": "",
            "confidence": 0.0,
            "error": str(e),
            "pipeline_used": pipeline_used,
            "company_linkedin_url": company_linkedin_url,
            "cache_hit": False,
        }

    # Cache result
    if use_cache:
        _PERSON_DISCOVERY_CACHE[cache_key] = result.copy()
        result["cache_hit"] = False

    return result


async def discover_people_two_step_async(
    rows: list[dict],
    company_key: str = "company",
    title_key: str = "title",
    actor_id: str = DEFAULT_PEOPLE_FINDER_ACTOR,
    max_results_per_row: int = 1,
    concurrency: int = DEFAULT_PEOPLE_CONCURRENCY,
    timeout_secs: int = DEFAULT_TIMEOUT_SECS,
    wait_secs: int = DEFAULT_WAIT_SECS,
    use_cache: bool = True,
    resolve_company_url: bool = True,
) -> list[dict]:
    """
    Two-step person discovery for multiple rows with caching and concurrency.

    Optimizations:
    - Caches company LinkedIn URLs (one lookup per unique company)
    - Caches person discovery results (skips duplicate company+title pairs)
    - Resolves company URLs first in batch, then searches people
    - Falls back to direct search if URL-based search returns 0 results
    - Only caches successful results (errors are not cached)

    Args:
        rows: List of dicts with company and title info
        company_key: Key containing company name
        title_key: Key containing job title
        actor_id: Apify actor for people search
        max_results_per_row: Max people per input row
        concurrency: Max concurrent operations
        timeout_secs: Per-run timeout
        wait_secs: Max polling wait
        use_cache: Whether to use result caching
        resolve_company_url: Whether to attempt 2-step pipeline

    Returns:
        List of discovery results, one per input row
    """
    try:
        from apify_client import ApifyClientAsync
    except ImportError:
        raise ApifyError("apify-client not installed. Run: pip install apify-client")

    config = load_apify_config()
    client = ApifyClientAsync(config.api_token)
    semaphore = asyncio.Semaphore(concurrency)

    # Build mapping of normalized company -> raw company name (for better API results)
    company_raw_map: dict[str, str] = {}
    unique_companies: set[str] = set()
    for row in rows:
        company = row.get(company_key, "").strip()
        if company:
            company_cache = _company_cache_key(company)
            unique_companies.add(company_cache)
            # Keep first raw name seen for each cache key
            if company_cache not in company_raw_map:
                company_raw_map[company_cache] = company

    async def call_actor_with_retries(
        actor_name: str,
        run_input: dict,
        timeout: int,
        wait: int,
        attempts: int = 3,
        base_delay: float = 2.0,
    ) -> tuple[dict, list]:
        """Call an actor with retries and return (run, items)."""
        last_error: Exception | None = None
        for attempt in range(attempts):
            try:
                run = await client.actor(actor_name).call(
                    run_input=run_input,
                    timeout_secs=timeout,
                    wait_secs=wait,
                )
                items_result = await client.dataset(run["defaultDatasetId"]).list_items()
                return run, items_result.items if items_result else []
            except Exception as exc:
                last_error = exc
                if attempt < attempts - 1:
                    await asyncio.sleep(base_delay * (2 ** attempt))
                else:
                    raise last_error

    # Resolve company URLs (batch with lower concurrency to avoid rate limits)
    if resolve_company_url:
        company_semaphore = asyncio.Semaphore(min(concurrency, 3))

        async def resolve_company_url_async(company_cache_key: str) -> tuple[str, str]:
            if company_cache_key in _COMPANY_LINKEDIN_CACHE:
                return company_cache_key, _COMPANY_LINKEDIN_CACHE[company_cache_key]

            # Use raw company name for better match quality
            raw_name = company_raw_map.get(company_cache_key, company_cache_key)

            async with company_semaphore:
                linkedin_url = ""
                domain = ""

                # Step 1: Try company enrichment with raw name (with retries)
                try:
                    _, items = await call_actor_with_retries(
                        actor_name=DEFAULT_COMPANY_ACTOR,
                        run_input={"name": raw_name, "country": "US"},
                        timeout=min(timeout_secs, 90),
                        wait=min(wait_secs, 180),
                    )

                    if items:
                        social_links = items[0].get("social_links", []) or []
                        linkedin_url = extract_social_link(social_links, "linkedin.com/company/")
                        # Also extract domain for secondary lookup
                        domain = extract_domain(items[0].get("official_website", "") or "")
                except Exception:
                    pass

                # Step 2: If no LinkedIn URL but have domain, try domain->LinkedIn actor (with retries)
                if not linkedin_url and domain:
                    run_input = {
                        "domain": domain,
                        "domains": [domain],
                        "domainName": domain,
                    }
                    try:
                        _, items2 = await call_actor_with_retries(
                            actor_name=DEFAULT_LINKEDIN_ACTOR,
                            run_input=run_input,
                            timeout=min(timeout_secs, 60),
                            wait=min(wait_secs, 120),
                        )
                        if items2:
                            linkedin_url = (
                                items2[0].get("linkedin_url")
                                or items2[0].get("linkedinUrl")
                                or items2[0].get("url")
                                or ""
                            )
                    except Exception:
                        pass

                _COMPANY_LINKEDIN_CACHE[company_cache_key] = linkedin_url
                return company_cache_key, linkedin_url

        # Only resolve companies not in cache
        companies_to_resolve = [c for c in unique_companies if c not in _COMPANY_LINKEDIN_CACHE]
        if companies_to_resolve:
            resolve_tasks = [resolve_company_url_async(c) for c in companies_to_resolve]
            await asyncio.gather(*resolve_tasks)

    # Helper to run a single people search
    async def run_people_search(
        query: str,
        company_slug: str,
        use_company_filter: bool,
    ) -> list[dict]:
        """Run a single people search with retries and return items."""
        if "harvestapi" in actor_id.lower():
            # HarvestAPI uses page-based pagination and mode for cost control
            # mode: "Short" = $0.10/page (search results only, no full profile fetch)
            # mode: "Full" = $0.10/page + $0.004/profile (default, expensive!)
            # takePages: 1 = limit to first page (~25 results)
            run_input = {
                "searchQuery": query,
                "mode": "Short",  # CRITICAL: avoid $0.004/profile charges
                "takePages": 1,   # Limit to 1 page (up to 25 results)
            }
            if use_company_filter and company_slug:
                run_input["companyId"] = company_slug
        elif "anchor" in actor_id.lower():
            run_input = {"search": query, "maxResults": max_results_per_row}
        else:
            run_input = {"search": query, "query": query, "maxResults": max_results_per_row}

        _, items = await call_actor_with_retries(
            actor_name=actor_id,
            run_input=run_input,
            timeout=min(timeout_secs, 90),
            wait=min(wait_secs, 180),
        )
        return items

    # Phase 2: Discover people using resolved company URLs
    async def discover_one(idx: int, row: dict) -> dict:
        company = row.get(company_key, "").strip()
        title = row.get(title_key, "").strip()
        company_cache_key = _company_cache_key(company)
        cache_key = _make_cache_key(company, title)

        # Check person cache (only hits for successful results)
        if use_cache and cache_key in _PERSON_DISCOVERY_CACHE:
            cached = _PERSON_DISCOVERY_CACHE[cache_key].copy()
            cached["idx"] = idx
            cached["input"] = row
            cached["cache_hit"] = True
            return cached

        if not company and not title:
            return {
                "idx": idx,
                "input": row,
                "first_name": "",
                "last_name": "",
                "full_name": "",
                "title": title,
                "company": company,
                "linkedin_url": "",
                "location": "",
                "headline": "",
                "confidence": 0.0,
                "error": "Empty company and title",
                "pipeline_used": "none",
                "company_linkedin_url": "",
                "cache_hit": False,
            }

        # Get company LinkedIn URL from cache
        company_linkedin_url = _COMPANY_LINKEDIN_CACHE.get(company_cache_key, "")

        # Extract company slug for filtering
        company_slug = ""
        if company_linkedin_url and "/company/" in company_linkedin_url:
            company_slug = company_linkedin_url.split("/company/")[-1].rstrip("/")

        async with semaphore:
            items = []
            pipeline_used = "direct"
            direct_query = _build_people_search_query(company, title)

            try:
                # Strategy 1: If we have company URL, try URL-based search first
                if company_linkedin_url and company_slug and "harvestapi" in actor_id.lower():
                    url_query = title.strip()
                    if url_query:
                        items = await run_people_search(url_query, company_slug, use_company_filter=True)
                        if items:
                            pipeline_used = "two_step"

                # Strategy 2: Fall back to direct text search if URL-based returned 0
                if not items and direct_query:
                    items = await run_people_search(direct_query, "", use_company_filter=False)
                    pipeline_used = "direct_fallback" if company_linkedin_url else "direct"

                if not items:
                    # No results from either strategy - don't cache this
                    return {
                        "idx": idx,
                        "input": row,
                        "first_name": "",
                        "last_name": "",
                        "full_name": "",
                        "title": title,
                        "company": company,
                        "linkedin_url": "",
                        "location": "",
                        "headline": "",
                        "confidence": 0.0,
                        "error": "No results found",
                        "pipeline_used": pipeline_used,
                        "company_linkedin_url": company_linkedin_url,
                        "cache_hit": False,
                    }

                person = _normalize_person_result(items[0], idx, company, title)
                result = {
                    "idx": idx,
                    "input": row,
                    "first_name": person.first_name,
                    "last_name": person.last_name,
                    "full_name": person.full_name,
                    "title": person.title,
                    "company": person.company,
                    "linkedin_url": person.linkedin_url,
                    "location": person.location,
                    "headline": person.headline,
                    "confidence": person.confidence,
                    "error": None,
                    "pipeline_used": pipeline_used,
                    "company_linkedin_url": company_linkedin_url,
                    "cache_hit": False,
                }

                # Only cache successful results (not errors/no-results)
                if use_cache and person.linkedin_url:
                    cache_entry = {k: v for k, v in result.items() if k not in ("idx", "input")}
                    _PERSON_DISCOVERY_CACHE[cache_key] = cache_entry

                return result

            except Exception as e:
                # Don't cache errors - they may be transient
                return {
                    "idx": idx,
                    "input": row,
                    "first_name": "",
                    "last_name": "",
                    "full_name": "",
                    "title": title,
                    "company": company,
                    "linkedin_url": "",
                    "location": "",
                    "headline": "",
                    "confidence": 0.0,
                    "error": str(e),
                    "pipeline_used": pipeline_used,
                    "company_linkedin_url": company_linkedin_url,
                    "cache_hit": False,
                }

    tasks = [discover_one(i, row) for i, row in enumerate(rows)]
    results = await asyncio.gather(*tasks)

    return sorted(results, key=lambda x: x["idx"])
