//! LinkedIn profile enrichment via Gmail email mining + Apify scraping.
//!
//! Pipeline:
//!
//! 1. Search Gmail (via Composio) for emails from `linkedin.com`.
//! 2. Extract a `linkedin.com/in/<slug>` profile URL from the results.
//! 3. Scrape the profile via the Apify actor `dev_fusion/linkedin-profile-scraper`.
//! 4. Persist the scraped profile data into the user-profile memory namespace.
//!
//! Designed to run once during onboarding as a fire-and-forget enrichment
//! pass. Each stage logs progress so the caller (or a future frontend
//! progress UI) can observe what happened.

use crate::openhuman::config::Config;
use crate::openhuman::integrations::{build_client, IntegrationClient};
use regex::Regex;
use serde_json::json;
use std::sync::{Arc, LazyLock};

/// Apify actor slug for the LinkedIn profile scraper.
const LINKEDIN_SCRAPER_ACTOR: &str = "dev_fusion/linkedin-profile-scraper";

/// Regex that captures a LinkedIn username from profile URLs.
///
/// Matches both the canonical form (`linkedin.com/in/<slug>`) and the
/// notification-email form (`linkedin.com/comm/in/<slug>`). The username
/// is captured in group 1 so we can reconstruct a clean canonical URL.
static LINKEDIN_USERNAME_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"https?://(?:www\.)?linkedin\.com/(?:comm/)?in/([a-zA-Z0-9_-]+)").unwrap()
});

/// Build the canonical profile URL from a username slug.
fn canonical_linkedin_url(username: &str) -> String {
    format!("https://www.linkedin.com/in/{username}")
}

/// Typed status for a pipeline stage.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StageStatus {
    Success,
    Failed,
    Skipped,
}

/// A single pipeline stage result, suitable for structured RPC responses.
#[derive(Debug, Clone, serde::Serialize)]
pub struct EnrichmentStage {
    pub id: String,
    pub status: StageStatus,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// Outcome of the full enrichment pipeline.
#[derive(Debug)]
pub struct LinkedInEnrichmentResult {
    /// The LinkedIn profile URL found in Gmail, if any.
    pub profile_url: Option<String>,
    /// Raw scraped profile JSON from Apify, if the scrape succeeded.
    pub profile_data: Option<serde_json::Value>,
    /// Typed stage results for structured consumption by the frontend.
    pub stages: Vec<EnrichmentStage>,
    /// Human-readable log lines for display.
    pub log: Vec<String>,
}

/// Run the full Gmail → LinkedIn → Apify enrichment pipeline.
///
/// `preset_profile_url` lets callers skip the Gmail-search stage and
/// supply a profile URL they already discovered out-of-band — currently
/// the frontend obtains one via the webview-driven
/// `gmail_find_linkedin_profile_url` Tauri command, which uses the
/// logged-in Gmail webview's CDP session instead of a Composio token.
/// When `None`, the function falls back to the Composio-driven Gmail
/// search at [`search_gmail_for_linkedin`] (which currently errors
/// because Composio Gmail was removed; callers should pass `Some` until
/// a Composio-free fallback ships).
///
/// Returns `Ok` with a result struct even if individual stages fail —
/// partial progress is still useful. Only returns `Err` if we can't
/// even build the integration client (i.e. user isn't signed in).
pub async fn run_linkedin_enrichment(
    config: &Config,
    preset_profile_url: Option<String>,
) -> anyhow::Result<LinkedInEnrichmentResult> {
    let mut result = LinkedInEnrichmentResult {
        profile_url: None,
        profile_data: None,
        stages: Vec::new(),
        log: Vec::new(),
    };

    // Short-circuit: if PROFILE.md is already on disk from a previous
    // enrichment run, skip the entire pipeline. The welcome agent reads
    // PROFILE.md straight from the workspace, so re-running stages 1-3
    // would just churn quota for the same output.
    let profile_path = config.workspace_dir.join("PROFILE.md");
    if profile_path.is_file() {
        tracing::info!(
            path = %profile_path.display(),
            "[linkedin_enrichment] PROFILE.md already exists — skipping pipeline"
        );
        result
            .log
            .push("PROFILE.md already exists — skipping enrichment.".into());
        for id in ["gmail-search", "apify-scrape", "build-profile"] {
            result.stages.push(EnrichmentStage {
                id: id.into(),
                status: StageStatus::Skipped,
                detail: Some("PROFILE.md already on disk".into()),
            });
        }
        return Ok(result);
    }

    let client = build_client(config)
        .ok_or_else(|| anyhow::anyhow!("no integration client — user not signed in"))?;

    // ── Stage 1: search Gmail for LinkedIn emails ───────────────────
    let profile_url = if let Some(url) = preset_profile_url {
        tracing::info!(url = %url, "[linkedin_enrichment] stage 1: using preset profile URL");
        result
            .log
            .push(format!("Using preset LinkedIn profile: {url}"));
        result.stages.push(EnrichmentStage {
            id: "gmail-search".into(),
            status: StageStatus::Success,
            detail: Some(url.clone()),
        });
        Some(url)
    } else {
        tracing::info!("[linkedin_enrichment] stage 1: searching Gmail for LinkedIn emails");
        result
            .log
            .push("Searching Gmail for LinkedIn emails...".into());
        match search_gmail_for_linkedin(config).await {
            Ok(Some(url)) => {
                tracing::info!(url = %url, "[linkedin_enrichment] found LinkedIn profile URL");
                result.log.push(format!("Found LinkedIn profile: {url}"));
                result.stages.push(EnrichmentStage {
                    id: "gmail-search".into(),
                    status: StageStatus::Success,
                    detail: Some(url.clone()),
                });
                Some(url)
            }
            Ok(None) => {
                tracing::info!("[linkedin_enrichment] no LinkedIn profile URL found in emails");
                result
                    .log
                    .push("No LinkedIn profile URL found in emails.".into());
                result.stages.push(EnrichmentStage {
                    id: "gmail-search".into(),
                    status: StageStatus::Skipped,
                    detail: Some("No LinkedIn profile URL found in emails".into()),
                });
                None
            }
            Err(e) => {
                tracing::warn!(error = %e, "[linkedin_enrichment] Gmail search failed");
                result.log.push(format!("Gmail search failed: {e}"));
                result.stages.push(EnrichmentStage {
                    id: "gmail-search".into(),
                    status: StageStatus::Failed,
                    detail: Some(format!("Gmail search failed: {e}")),
                });
                None
            }
        }
    };

    result.profile_url = profile_url.clone();

    // ── Stage 2: scrape the LinkedIn profile via Apify ───────────────
    let Some(url) = profile_url else {
        result
            .log
            .push("Skipping LinkedIn scrape — no profile URL.".into());
        result.stages.push(EnrichmentStage {
            id: "apify-scrape".into(),
            status: StageStatus::Skipped,
            detail: Some("No profile URL to scrape".into()),
        });
        result.stages.push(EnrichmentStage {
            id: "build-profile".into(),
            status: StageStatus::Skipped,
            detail: Some("No profile data".into()),
        });
        return Ok(result);
    };

    tracing::info!(url = %url, "[linkedin_enrichment] stage 2: scraping LinkedIn profile via Apify");
    result.log.push("Scraping LinkedIn profile...".into());

    // Build memory client once for all persist calls.
    let memory = match build_memory_client() {
        Ok(m) => Some(m),
        Err(e) => {
            tracing::warn!(
                error = %e,
                "[linkedin_enrichment] memory client init failed, skipping memory persistence"
            );
            None
        }
    };

    match scrape_linkedin_profile(&client, &url).await {
        Ok(data) => {
            tracing::info!("[linkedin_enrichment] Apify scrape succeeded");
            result
                .log
                .push("LinkedIn profile scraped successfully.".into());
            result.stages.push(EnrichmentStage {
                id: "apify-scrape".into(),
                status: StageStatus::Success,
                detail: None,
            });

            // ── Stage 3: write PROFILE.md to workspace ──────────────
            tracing::info!("[linkedin_enrichment] stage 3: writing PROFILE.md");
            if let Err(e) = write_profile_md(config, &url, &data).await {
                tracing::warn!(error = %e, "[linkedin_enrichment] failed to write PROFILE.md");
                result.log.push(format!("Failed to write PROFILE.md: {e}"));
                result.stages.push(EnrichmentStage {
                    id: "build-profile".into(),
                    status: StageStatus::Failed,
                    detail: Some(format!("{e}")),
                });
            } else {
                result.log.push("PROFILE.md written to workspace.".into());
                result.stages.push(EnrichmentStage {
                    id: "build-profile".into(),
                    status: StageStatus::Success,
                    detail: Some("PROFILE.md written".into()),
                });
            }

            // Also persist to memory store for RAG retrieval.
            if let Some(ref mem) = memory {
                if let Err(e) = persist_linkedin_profile(mem, &url, &data).await {
                    tracing::warn!(error = %e, "[linkedin_enrichment] failed to persist to memory");
                }
            }

            result.profile_data = Some(data);
        }
        Err(e) => {
            tracing::warn!(error = %e, "[linkedin_enrichment] Apify scrape failed");
            result.log.push(format!("LinkedIn scrape failed: {e}"));
            result.stages.push(EnrichmentStage {
                id: "apify-scrape".into(),
                status: StageStatus::Failed,
                detail: Some(format!("{e}")),
            });
            result.stages.push(EnrichmentStage {
                id: "build-profile".into(),
                status: StageStatus::Skipped,
                detail: Some("Scrape failed".into()),
            });

            // Still write a minimal PROFILE.md with just the URL.
            if let Err(e) = write_profile_md_url_only(config, &url) {
                tracing::warn!(error = %e, "[linkedin_enrichment] failed to write PROFILE.md");
            }
            if let Some(ref mem) = memory {
                let _ = persist_linkedin_url_only(mem, &url).await;
            }
        }
    }

    Ok(result)
}

// ── PROFILE.md generation ────────────────────────────────────────────

/// Summarise the scraped LinkedIn data with an LLM, then write the
/// result to `{workspace_dir}/PROFILE.md`. The prompt system picks this
/// file up automatically on the next agent turn.
async fn write_profile_md(
    config: &Config,
    url: &str,
    data: &serde_json::Value,
) -> anyhow::Result<()> {
    // First render a full Markdown draft from the raw data.
    let raw_md = render_profile_markdown(url, data);

    // Then compress it through the LLM.
    let md = match summarise_profile_with_llm(config, &raw_md).await {
        Ok(summary) => summary,
        Err(e) => {
            tracing::warn!(
                error = %e,
                "[linkedin_enrichment] LLM summarisation failed, falling back to raw markdown"
            );
            raw_md
        }
    };

    let path = config.workspace_dir.join("PROFILE.md");
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, &md)?;
    tracing::info!(path = %path.display(), len = md.len(), "[linkedin_enrichment] wrote PROFILE.md");
    Ok(())
}

/// Ask the backend LLM to distil the raw LinkedIn Markdown into a
/// concise, high-signal profile document suitable for agent context.
pub async fn summarise_profile_with_llm(config: &Config, raw_md: &str) -> anyhow::Result<String> {
    use crate::openhuman::providers::ops::{
        create_backend_inference_provider, ProviderRuntimeOptions,
    };

    // Point `AuthService` at the same state dir the rest of the app uses
    // (the openhuman_dir derived from `config.config_path`), otherwise
    // `OpenHumanBackendProvider::resolve_bearer` looks in `~/.openhuman`
    // and fails with "No backend session" even when the JWT is present
    // under a custom `OPENHUMAN_WORKSPACE`.
    let options = ProviderRuntimeOptions {
        auth_profile_override: None,
        openhuman_dir: config
            .config_path
            .parent()
            .map(std::path::PathBuf::from)
            .or_else(|| Some(config.workspace_dir.clone())),
        secrets_encrypt: config.secrets.encrypt,
        reasoning_enabled: config.runtime.reasoning_enabled,
    };
    let provider = create_backend_inference_provider(
        config.api_url.as_deref(),
        config.api_key.as_deref(),
        &options,
    )?;

    let system = "\
You are a profile analyst. You will receive a user's LinkedIn profile in Markdown format. \
Your job is to produce a concise PROFILE.md that an AI assistant will read to understand \
who this user is.\n\n\
Rules:\n\
- Output clean Markdown with a `# User Profile` heading.\n\
- Lead with name, headline, location, and LinkedIn URL.\n\
- Summarise the About section in 2-3 sentences max.\n\
- List only the most notable experiences (founder roles, leadership positions) — skip \
  short stints and minor roles.\n\
- Include education, languages, and any standout achievements.\n\
- Add a short `## Key facts for the assistant` section with 5-8 bullet points the AI \
  should know (e.g. expertise areas, industries, current focus, communication style hints).\n\
- Keep the entire output under 400 words.\n\
- Do not invent information — only use what is in the input.";

    let model = "summarization-v1";

    tracing::debug!(
        model = model,
        input_len = raw_md.len(),
        "[linkedin_enrichment] sending profile to LLM for summarisation"
    );

    let summary = provider
        .chat_with_system(Some(system), raw_md, model, 0.3)
        .await?;

    tracing::debug!(
        output_len = summary.len(),
        "[linkedin_enrichment] LLM summarisation complete"
    );

    Ok(summary)
}

/// Minimal fallback when the Apify scrape failed but we have the URL.
fn write_profile_md_url_only(config: &Config, url: &str) -> anyhow::Result<()> {
    let md = format!(
        "# User Profile\n\n\
         LinkedIn: {url}\n\n\
         _Full profile data was not available at onboarding time._\n"
    );
    let path = config.workspace_dir.join("PROFILE.md");
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, md)?;
    Ok(())
}

/// Turn the Apify scrape JSON into clean Markdown.
pub fn render_profile_markdown(url: &str, data: &serde_json::Value) -> String {
    let s = |key: &str| {
        data.get(key)
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string()
    };

    let full_name = s("fullName");
    let headline = s("headline");
    let location = s("addressWithCountry");
    let about = s("about");
    let connections = data.get("connections").and_then(|v| v.as_u64());
    let followers = data.get("followers").and_then(|v| v.as_u64());

    let mut md = format!("# User Profile — {full_name}\n\n");

    if !headline.is_empty() {
        md.push_str(&format!("**{headline}**\n\n"));
    }
    if !location.is_empty() {
        md.push_str(&format!("Location: {location}\n\n"));
    }
    md.push_str(&format!("LinkedIn: {url}\n\n"));
    if let (Some(c), Some(f)) = (connections, followers) {
        md.push_str(&format!("Connections: {c} | Followers: {f}\n\n"));
    }

    if !about.is_empty() {
        md.push_str("## About\n\n");
        md.push_str(&about);
        md.push_str("\n\n");
    }

    // Experience
    if let Some(exps) = data.get("experiences").and_then(|v| v.as_array()) {
        if !exps.is_empty() {
            md.push_str("## Experience\n\n");
            for exp in exps {
                let title = exp.get("title").and_then(|v| v.as_str()).unwrap_or("");
                let company = exp.get("subtitle").and_then(|v| v.as_str()).unwrap_or("");
                let duration = exp.get("duration").and_then(|v| v.as_str()).unwrap_or("");
                let caption = exp.get("caption").and_then(|v| v.as_str()).unwrap_or("");
                let desc = exp
                    .get("description")
                    .and_then(|v| v.as_str())
                    .unwrap_or("");
                md.push_str(&format!("- **{title}**"));
                if !company.is_empty() {
                    md.push_str(&format!(" at {company}"));
                }
                if !duration.is_empty() {
                    md.push_str(&format!(" ({duration})"));
                }
                if !caption.is_empty() {
                    md.push_str(&format!(" — {caption}"));
                }
                md.push('\n');
                if !desc.is_empty() {
                    md.push_str(&format!("  {desc}\n"));
                }
            }
            md.push('\n');
        }
    }

    // Education
    if let Some(edus) = data.get("educations").and_then(|v| v.as_array()) {
        if !edus.is_empty() {
            md.push_str("## Education\n\n");
            for edu in edus {
                let school = edu.get("title").and_then(|v| v.as_str()).unwrap_or("");
                let degree = edu.get("subtitle").and_then(|v| v.as_str()).unwrap_or("");
                md.push_str(&format!("- **{school}**"));
                if !degree.is_empty() {
                    md.push_str(&format!(" — {degree}"));
                }
                md.push('\n');
            }
            md.push('\n');
        }
    }

    // Languages
    if let Some(langs) = data.get("languages").and_then(|v| v.as_array()) {
        if !langs.is_empty() {
            let names: Vec<&str> = langs
                .iter()
                .filter_map(|l| l.get("name").and_then(|v| v.as_str()))
                .collect();
            if !names.is_empty() {
                md.push_str(&format!("Languages: {}\n\n", names.join(", ")));
            }
        }
    }

    // Volunteering
    if let Some(vols) = data.get("volunteering").and_then(|v| v.as_array()) {
        if !vols.is_empty() {
            md.push_str("## Volunteering\n\n");
            for vol in vols {
                let title = vol.get("title").and_then(|v| v.as_str()).unwrap_or("");
                let org = vol.get("subtitle").and_then(|v| v.as_str()).unwrap_or("");
                md.push_str(&format!("- {title}"));
                if !org.is_empty() {
                    md.push_str(&format!(" at {org}"));
                }
                md.push('\n');
            }
            md.push('\n');
        }
    }

    md
}

// ── Internal helpers ─────────────────────────────────────────────────

/// Search Gmail via Composio for emails from linkedin.com and extract
/// the user's own LinkedIn username.
///
/// LinkedIn notification emails embed `comm/in/<username>` links in the
/// **HTML body** — which Gmail returns as base64-encoded data inside
/// `payload.parts[].body.data`. We must decode those parts before
/// regex-matching; searching the raw JSON alone misses them.
async fn search_gmail_for_linkedin(config: &Config) -> anyhow::Result<Option<String>> {
    use crate::openhuman::composio::client::build_composio_client;
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine;

    let client = build_composio_client(config)
        .ok_or_else(|| anyhow::anyhow!("composio client unavailable"))?;

    // `comm/in/<username>` — LinkedIn's own notification emails always use
    // this form to refer to the email *recipient's* profile.
    static COMM_RE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"linkedin\.com/comm/in/([a-zA-Z0-9_-]+)").unwrap());

    let resp = client
        .execute_tool(
            "GMAIL_FETCH_EMAILS",
            Some(json!({
                "query": "from:linkedin.com",
                "max_results": 10,
            })),
        )
        .await
        .map_err(|e| anyhow::anyhow!("GMAIL_FETCH_EMAILS failed: {e:#}"))?;

    if !resp.successful {
        let err = resp.error.unwrap_or_else(|| "unknown error".into());
        anyhow::bail!("GMAIL_FETCH_EMAILS error: {err}");
    }

    // Walk the messages, decode HTML parts, and search for profile URLs.
    let messages = resp
        .data
        .get("messages")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    for msg in &messages {
        // Collect all text to search: plain messageText + decoded HTML parts.
        let mut searchable = String::new();

        // Plain text body (already decoded by Composio).
        if let Some(text) = msg.get("messageText").and_then(|v| v.as_str()) {
            searchable.push_str(text);
            searchable.push('\n');
        }

        // Decode base64 HTML parts from payload.parts[].body.data.
        if let Some(parts) = msg.pointer("/payload/parts").and_then(|v| v.as_array()) {
            for part in parts {
                let is_html = part
                    .get("mimeType")
                    .and_then(|v| v.as_str())
                    .is_some_and(|m| m.contains("html"));
                if !is_html {
                    continue;
                }
                if let Some(b64) = part.pointer("/body/data").and_then(|v| v.as_str()) {
                    if let Ok(bytes) = URL_SAFE_NO_PAD.decode(b64) {
                        if let Ok(html) = String::from_utf8(bytes) {
                            searchable.push_str(&html);
                            searchable.push('\n');
                        }
                    }
                }
            }
        }

        // Priority 1: comm/in/<username> — always the recipient's own profile.
        if let Some(caps) = COMM_RE.captures(&searchable) {
            let username = caps[1].to_string();
            let url = canonical_linkedin_url(&username);
            tracing::info!(
                username = %username,
                url = %url,
                "[linkedin_enrichment] found own username via comm/in/ in HTML body"
            );
            return Ok(Some(url));
        }

        // Priority 2: canonical /in/<username> (some notification types).
        if let Some(caps) = LINKEDIN_USERNAME_RE.captures(&searchable) {
            let username = caps[1].to_string();
            let url = canonical_linkedin_url(&username);
            tracing::info!(
                username = %username,
                url = %url,
                "[linkedin_enrichment] found username via /in/ in email body"
            );
            return Ok(Some(url));
        }
    }

    Ok(None)
}

/// Call the Apify LinkedIn profile scraper synchronously and return the
/// first profile item from the dataset.
pub async fn scrape_linkedin_profile(
    client: &Arc<IntegrationClient>,
    profile_url: &str,
) -> anyhow::Result<serde_json::Value> {
    let body = json!({
        "actorId": LINKEDIN_SCRAPER_ACTOR,
        "input": {
            "profileUrls": [profile_url],
        },
        "sync": true,
        "timeoutSecs": 120,
    });

    tracing::debug!(
        actor = LINKEDIN_SCRAPER_ACTOR,
        url_len = profile_url.len(),
        "[linkedin_enrichment] invoking Apify actor"
    );

    // The backend wraps the Apify response in its standard envelope.
    // `IntegrationClient::post` already unwraps `{ success, data }`.
    let resp: serde_json::Value = client
        .post("/agent-integrations/apify/run", &body)
        .await
        .map_err(|e| anyhow::anyhow!("Apify run failed: {e:#}"))?;

    let status = resp
        .get("status")
        .and_then(|v| v.as_str())
        .unwrap_or("UNKNOWN");

    if status != "SUCCEEDED" {
        anyhow::bail!("Apify run finished with status: {status}");
    }

    // Extract the first item from the inline results array.
    let items = resp
        .get("items")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow::anyhow!("Apify run returned no items array"))?;

    items
        .first()
        .cloned()
        .ok_or_else(|| anyhow::anyhow!("Apify run returned an empty items array"))
}

/// Build a local memory client for profile persistence.
fn build_memory_client() -> anyhow::Result<crate::openhuman::memory::store::MemoryClient> {
    crate::openhuman::memory::store::MemoryClient::new_local()
        .map_err(|e| anyhow::anyhow!("memory client unavailable: {e}"))
}

/// Persist the full scraped LinkedIn profile to the user-profile memory
/// namespace so the agent has rich context about the user.
async fn persist_linkedin_profile(
    memory: &crate::openhuman::memory::store::MemoryClient,
    url: &str,
    data: &serde_json::Value,
) -> anyhow::Result<()> {
    let content = format!(
        "LinkedIn profile for {url}:\n\n{}",
        serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
    );

    memory
        .store_skill_sync(
            "user-profile", // namespace skill_id
            "linkedin",     // integration_id
            &format!("LinkedIn profile: {url}"),
            &content,
            Some("onboarding-linkedin-enrichment".into()),
            Some(json!({
                "source": "apify-linkedin-scraper",
                "url": url,
                "actor": LINKEDIN_SCRAPER_ACTOR,
            })),
            Some("high".into()),
            None, // created_at
            None, // updated_at
            None, // document_id
        )
        .await
        .map_err(|e| anyhow::anyhow!("memory store failed: {e}"))
}

/// Fallback: persist just the LinkedIn URL when the full scrape fails.
async fn persist_linkedin_url_only(
    memory: &crate::openhuman::memory::store::MemoryClient,
    url: &str,
) -> anyhow::Result<()> {
    memory
        .store_skill_sync(
            "user-profile",
            "linkedin",
            &format!("LinkedIn profile URL: {url}"),
            &format!("User LinkedIn profile: {url}"),
            Some("onboarding-linkedin-url".into()),
            Some(json!({ "source": "gmail-linkedin-extraction", "url": url })),
            Some("medium".into()),
            None, // created_at
            None, // updated_at
            None, // document_id
        )
        .await
        .map_err(|e| anyhow::anyhow!("memory store failed: {e}"))
}

#[cfg(test)]
#[path = "linkedin_enrichment_tests.rs"]
mod tests;
