"""Nimbleway scraper provider implementation."""

from __future__ import annotations

import logging
import re
from urllib.parse import urlparse
from typing import Any, Dict, List

from g_gremlin.nimbleway import (
    NimblewayClient,
    NimblewayError,
    extract_company_profile,
    extract_person_profile,
    normalize_social_profile_reference,
)

from .scraper_provider import (
    SCRAPER_PROVIDER_REGISTRY,
    ScraperProvider,
    ToolCatalogEntry,
    ToolField,
    extract_inputs_from_row,
    filter_output_fields,
    run_test_generic,
)

logger = logging.getLogger(__name__)


def _extract_title_from_markdown(markdown: str) -> str:
    for line in markdown.splitlines():
        line = line.strip()
        if not line:
            continue
        if line.startswith("#"):
            return line.lstrip("#").strip()
        return line
    return ""


def _extract_title_from_html(html: str) -> str:
    match = re.search(r"<title>(.*?)</title>", html, flags=re.IGNORECASE | re.DOTALL)
    if not match:
        return ""
    return re.sub(r"\s+", " ", match.group(1)).strip()


def _html_to_text(html: str) -> str:
    text = re.sub(r"<[^>]+>", " ", html or "")
    return re.sub(r"\s+", " ", text).strip()


def _markdown_to_text(markdown: str) -> str:
    text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", markdown or "")
    text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
    text = re.sub(r"`{1,3}", "", text)
    text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
    text = re.sub(r"[*_>~-]", " ", text)
    return re.sub(r"\s+", " ", text).strip()


def _extract_page_transform(response: Dict[str, Any], inputs: Dict[str, Any]) -> Dict[str, Any]:
    data = response.get("data") or {}
    markdown = str(data.get("markdown") or "")
    html = str(data.get("html") or "")
    title = _extract_title_from_markdown(markdown) or _extract_title_from_html(html) or str(inputs.get("url") or "")
    text = _markdown_to_text(markdown) or _html_to_text(html)
    return {
        "title": title,
        "text": text,
        "markdown": markdown,
    }


def _extract_company_site_transform(response: Dict[str, Any], inputs: Dict[str, Any]) -> Dict[str, Any]:
    source_url = str(inputs.get("company_url") or "")
    profile = extract_company_profile(response, source_url=source_url)

    return {
        "company_name": str(profile.get("company_name") or ""),
        "description": str(profile.get("description") or ""),
        "category": str(profile.get("category") or ""),
        "website": str(profile.get("website") or ""),
        "linkedin_url": str(profile.get("linkedin_url") or ""),
        "follower_count": str(profile.get("follower_count") or ""),
        "hq_location": str(profile.get("hq_location") or ""),
        "source_url": source_url,
    }


_SEARCH_DISCOVERY_BLOCKLIST = {
    "linkedin.com",
    "facebook.com",
    "x.com",
    "twitter.com",
    "instagram.com",
    "youtube.com",
    "wikipedia.org",
    "crunchbase.com",
    "bloomberg.com",
    "glassdoor.com",
    "g2.com",
    "capterra.com",
    "trustradius.com",
    "rocketreach.co",
    "theorg.com",
    "apollo.io",
    "zoominfo.com",
}


def _is_linkedin_company_result(url: str) -> bool:
    domain = _candidate_domain(url)
    path = urlparse(url).path.strip("/")
    return domain == "linkedin.com" and (
        path.startswith("company/") or
        path.startswith("school/") or
        path.startswith("showcase/")
    )


def _website_root(url: str) -> str:
    parsed = urlparse(url or "")
    if not parsed.netloc:
        return ""
    scheme = parsed.scheme or "https"
    return f"{scheme}://{parsed.netloc}"


def _is_official_website_candidate(url: str) -> bool:
    domain = _candidate_domain(url)
    if not domain or domain in _SEARCH_DISCOVERY_BLOCKLIST:
        return False
    if _is_linkedin_company_result(url):
        return False
    return True


def _pick_best_discovery_result(items: List[Dict[str, Any]]) -> Dict[str, Any]:
    for item in items:
        url = str(item.get("url") or "")
        if _is_official_website_candidate(url):
            return item
    for item in items:
        url = str(item.get("url") or "")
        if _is_linkedin_company_result(url):
            return item
    return items[0] if items else {}


def _search_web_transform(response: Dict[str, Any], _inputs: Dict[str, Any]) -> Dict[str, Any]:
    results = response.get("results") or []
    items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
    first = _pick_best_discovery_result(items)
    if not isinstance(first, dict):
        first = {}
    official_website = ""
    linkedin_company_url = ""
    for item in items:
        item_url = str(item.get("url") or "")
        if not official_website and _is_official_website_candidate(item_url):
            official_website = _website_root(item_url)
        if not linkedin_company_url and _is_linkedin_company_result(item_url):
            linkedin_company_url = item_url
        if official_website and linkedin_company_url:
            break
    snippet = first.get("description") or first.get("content") or first.get("snippet") or ""
    return {
        "title": str(first.get("title") or ""),
        "url": str(first.get("url") or ""),
        "snippet": str(snippet or ""),
        "result_domain": _candidate_domain(str(first.get("url") or "")),
        "official_website": official_website,
        "linkedin_company_url": linkedin_company_url,
    }


# ---------------------------------------------------------------------------
# Web Signals: preset-driven search_web variants
# ---------------------------------------------------------------------------

_WEB_SIGNAL_PRESETS: Dict[str, Dict[str, Any]] = {
    "hiring_news": {
        "query_template": "{company} hiring OR careers OR job openings",
        "label": "Hiring Signals",
    },
    "recent_news": {
        "query_template": "{company} news OR announcement OR press release",
        "label": "Recent News",
    },
    "product_launches": {
        "query_template": "{company} product launch OR new product OR release",
        "label": "Product Launches",
    },
    "partnerships": {
        "query_template": "{company} partnership OR acquisition OR collaboration",
        "label": "Partnerships",
    },
    "thought_leadership": {
        "query_template": "{company} CEO OR founder interview OR keynote OR podcast",
        "label": "Thought Leadership",
    },
}


def _web_signals_transform(response: Dict[str, Any], inputs: Dict[str, Any]) -> Dict[str, Any]:
    preset_id = str(inputs.get("signal_preset") or "recent_news")
    preset = _WEB_SIGNAL_PRESETS.get(preset_id, _WEB_SIGNAL_PRESETS["recent_news"])

    results = response.get("results") or []
    items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
    first = items[0] if items else {}

    snippet = first.get("description") or first.get("content") or first.get("snippet") or ""
    return {
        "signal_type": preset.get("label", preset_id),
        "title": str(first.get("title") or ""),
        "url": str(first.get("url") or ""),
        "snippet": str(snippet or ""),
        "results_count": str(len(items)),
    }


# ---------------------------------------------------------------------------
# Hiring Signals: search + extract pipeline
# ---------------------------------------------------------------------------

def _parse_role_keywords(raw: str) -> List[str]:
    return [kw.strip() for kw in re.split(r"[,;|]+", str(raw or "")) if kw.strip()]


def _hiring_signals_transform(
    search_response: Dict[str, Any],
    extract_response: Dict[str, Any] | None,
    inputs: Dict[str, Any],
) -> Dict[str, Any]:
    role_keywords = _parse_role_keywords(inputs.get("role_keywords", ""))
    dept_keywords = _parse_role_keywords(inputs.get("department_keywords", ""))
    loc_keywords = _parse_role_keywords(inputs.get("location_keywords", ""))

    results = search_response.get("results") or []
    items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []

    page_text = ""
    if extract_response:
        data = extract_response.get("data") or {}
        md = str(data.get("markdown") or "")
        htm = str(data.get("html") or "")
        page_text = _markdown_to_text(md) or _html_to_text(htm)

    haystack = page_text.lower() if page_text else ""
    for item in items:
        haystack += " " + str(item.get("title") or "").lower()
        haystack += " " + str(item.get("description") or item.get("snippet") or "").lower()

    matching_titles: List[str] = []
    matching_depts: List[str] = []
    matching_locs: List[str] = []

    for kw in role_keywords:
        if kw.lower() in haystack:
            matching_titles.append(kw)
    for kw in dept_keywords:
        if kw.lower() in haystack:
            matching_depts.append(kw)
    for kw in loc_keywords:
        if kw.lower() in haystack:
            matching_locs.append(kw)

    is_hiring = len(matching_titles) > 0
    jobs_url = ""
    for item in items:
        url = str(item.get("url") or "")
        if url:
            jobs_url = url
            break

    role_count_match = re.search(r"(\d+)\+?\s*(?:open|job|position|role)", haystack)
    estimated_total = role_count_match.group(1) if role_count_match else ""

    req_keywords: List[str] = []
    for kw in role_keywords + dept_keywords:
        if kw.lower() in haystack:
            req_keywords.append(kw)

    req_summary = ""
    if page_text:
        sentences = re.split(r"[.!?]+", page_text)
        for sentence in sentences:
            lower_s = sentence.lower()
            if any(kw.lower() in lower_s for kw in role_keywords):
                req_summary = sentence.strip()[:320]
                break

    return {
        "is_hiring": "yes" if is_hiring else "no",
        "matching_roles_count": str(len(matching_titles)),
        "matching_role_titles": ", ".join(matching_titles),
        "matching_departments": ", ".join(matching_depts),
        "matching_locations": ", ".join(matching_locs),
        "estimated_total_open_roles": estimated_total,
        "requirements_summary": req_summary,
        "requirements_keywords_found": ", ".join(req_keywords),
        "jobs_source_url": jobs_url,
        "jobs_fetch_status": "ok" if items else "no_results",
    }


# ---------------------------------------------------------------------------
# Job Requirements Research: extract structured info from a job posting
# ---------------------------------------------------------------------------

def _job_requirements_transform(response: Dict[str, Any], inputs: Dict[str, Any]) -> Dict[str, Any]:
    data = response.get("data") or {}
    markdown = str(data.get("markdown") or "")
    html_text = str(data.get("html") or "")
    page_text = _markdown_to_text(markdown) or _html_to_text(html_text)
    title = _extract_title_from_markdown(markdown) or _extract_title_from_html(html_text)

    lower_text = page_text.lower()

    def _extract_section(markers: List[str], stop_markers: List[str] | None = None) -> str:
        for marker in markers:
            idx = lower_text.find(marker.lower())
            if idx == -1:
                continue
            start = idx + len(marker)
            end = len(page_text)
            for stop in (stop_markers or []):
                stop_idx = lower_text.find(stop.lower(), start)
                if stop_idx != -1 and stop_idx < end:
                    end = stop_idx
            return page_text[start:end].strip()[:500]
        return ""

    section_stops = ["responsibilities", "requirements", "qualifications",
                     "about us", "about the role", "what you'll do",
                     "what we offer", "benefits", "compensation", "salary",
                     "location", "apply"]

    responsibilities = _extract_section(
        ["responsibilities", "what you'll do", "what you will do", "about the role"],
        section_stops,
    )
    requirements = _extract_section(
        ["requirements", "qualifications", "what we're looking for", "who you are"],
        section_stops,
    )

    tools_stack = ""
    stack_match = re.search(
        r"(?:technologies|tech stack|tools|stack)[:\s]*([^.]{10,200})",
        page_text, flags=re.IGNORECASE,
    )
    if stack_match:
        tools_stack = stack_match.group(1).strip()

    seniority = ""
    for level in ["senior", "staff", "principal", "lead", "junior", "entry", "intern", "director", "vp", "head of"]:
        if level in lower_text:
            seniority = level.title()
            break

    location = ""
    loc_match = re.search(
        r"(?:location|based in|office)[:\s]+([^.]{5,100})",
        page_text, flags=re.IGNORECASE,
    )
    if loc_match:
        location = loc_match.group(1).strip()

    compensation = ""
    comp_match = re.search(
        r"(?:salary|compensation|pay)[:\s]*([^.]{5,150})",
        page_text, flags=re.IGNORECASE,
    )
    if not comp_match:
        comp_match = re.search(r"\$[\d,]+(?:\s*[-–]\s*\$[\d,]+)?(?:\s*/\s*(?:yr|year|annually))?", page_text)
    if comp_match:
        compensation = comp_match.group(0).strip() if comp_match.lastindex is None else comp_match.group(1).strip()

    return {
        "job_title": title,
        "responsibilities": responsibilities,
        "requirements": requirements,
        "tools_stack": tools_stack,
        "seniority": seniority,
        "location": location,
        "compensation": compensation,
        "source_url": str(inputs.get("job_posting_url") or ""),
        "job_fetch_status": "ok" if page_text else "empty_page",
    }


def _status_code(response: Dict[str, Any]) -> int | None:
    value = response.get("status_code")
    if value is None:
        return None
    try:
        return int(float(value))
    except (TypeError, ValueError):
        return None


def _candidate_domain(url: str | None) -> str:
    host = urlparse(str(url or "")).netloc.lower().strip()
    return host[4:] if host.startswith("www.") else host


def _is_supported_social_profile_url(url: str | None) -> bool:
    return bool(normalize_social_profile_reference(url))


def _blank_social_research_result(
    source_url: str,
    fetch_status: str,
    *,
    error_message: str = "",
) -> Dict[str, Any]:
    row_status = "skipped" if fetch_status == "unsupported_domain" else "error"
    domain = _candidate_domain(source_url)
    if "linkedin.com" in domain:
        source_type = "linkedin"
    elif domain in {"x.com", "twitter.com"}:
        source_type = "x"
    else:
        source_type = ""
    return {
        "person_name": "",
        "headline_or_bio": "",
        "current_role": "",
        "current_company": "",
        "location": "",
        "source_type": source_type,
        "source_url": source_url,
        "profile_fetch_status": fetch_status,
        "__row_status__": row_status,
        "error_message": error_message or fetch_status.replace("_", " "),
    }


def _is_blocked_profile_response(response: Dict[str, Any]) -> bool:
    code = _status_code(response)
    if code in {401, 403, 429, 999}:
        return True
    data = response.get("data") or {}
    haystack = " ".join(
        str(data.get(key) or "")
        for key in ("html", "markdown")
    ).lower()
    blocked_markers = (
        "captcha",
        "security verification",
        "verify you are human",
        "unusual activity",
        "access denied",
        "temporarily unavailable",
    )
    return any(marker in haystack for marker in blocked_markers)


def _social_person_research_transform(response: Dict[str, Any], inputs: Dict[str, Any]) -> Dict[str, Any]:
    source_url = str(inputs.get("linkedin_url") or "")
    if not _is_supported_social_profile_url(source_url):
        return _blank_social_research_result(source_url, "unsupported_domain")

    code = _status_code(response)
    if code == 404:
        return _blank_social_research_result(source_url, "not_found")
    if _is_blocked_profile_response(response):
        return _blank_social_research_result(source_url, "blocked")

    profile = extract_person_profile(response, source_url=source_url)
    has_signal = any(
        str(profile.get(key) or "").strip()
        for key in ("person_name", "headline_or_bio", "current_role", "current_company", "location")
    )
    if not has_signal:
        return _blank_social_research_result(source_url, "empty_profile")

    return {
        "person_name": str(profile.get("person_name") or ""),
        "headline_or_bio": str(profile.get("headline_or_bio") or ""),
        "current_role": str(profile.get("current_role") or ""),
        "current_company": str(profile.get("current_company") or ""),
        "location": str(profile.get("location") or ""),
        "source_type": str(profile.get("source_type") or "linkedin"),
        "source_url": str(profile.get("source_url") or source_url),
        "profile_fetch_status": "ok",
        "__row_status__": "scraped",
    }


NIMBLEWAY_TRANSFORMS = {
    "social_person_research_transform": _social_person_research_transform,
    "extract_page_transform": _extract_page_transform,
    "extract_company_site_transform": _extract_company_site_transform,
    "search_web_transform": _search_web_transform,
    "web_signals_transform": _web_signals_transform,
    "job_requirements_transform": _job_requirements_transform,
}


NIMBLEWAY_TOOL_CATALOG: Dict[str, ToolCatalogEntry] = {
    "social_person_research": ToolCatalogEntry(
        id="social_person_research",
        provider="nimbleway",
        display_name="Social Profile Details",
        description="Research a public LinkedIn or X profile URL and return structured outreach-ready context.",
        provider_tool_id="social_profile",
        input_schema=[
            ToolField(key="linkedin_url", label="Profile URL", required=True, type="url"),
        ],
        output_schema=[
            ToolField(key="person_name", label="Person Name", default=True),
            ToolField(key="headline_or_bio", label="Headline or Bio", default=True),
            ToolField(key="current_role", label="Current Role", default=True),
            ToolField(key="current_company", label="Current Company", default=True),
            ToolField(key="location", label="Location", default=True),
            ToolField(key="source_type", label="Source Type", default=True),
            ToolField(key="source_url", label="Source URL", default=True),
            ToolField(key="profile_fetch_status", label="Fetch Status", default=True),
        ],
        output_column_prefix="LI_",
        transform="social_person_research_transform",
    ),
    "extract_company_site": ToolCatalogEntry(
        id="extract_company_site",
        provider="nimbleway",
        display_name="Company Snapshot",
        description="Build a B2B-ready account snapshot from a company website or LinkedIn company page.",
        provider_tool_id="extract",
        input_schema=[
            ToolField(key="company_url", label="Company URL", required=True, type="url"),
        ],
        output_schema=[
            ToolField(key="company_name", label="Company Name", default=True),
            ToolField(key="description", label="Description", default=True),
            ToolField(key="category", label="Category", default=True),
            ToolField(key="website", label="Website", default=True),
            ToolField(key="linkedin_url", label="LinkedIn URL", default=True),
            ToolField(key="hq_location", label="HQ Location", default=False),
            ToolField(key="follower_count", label="LinkedIn Followers", default=False),
            ToolField(key="source_url", label="Source URL", default=True),
        ],
        output_column_prefix="Company_",
        transform="extract_company_site_transform",
    ),
    "hiring_signals": ToolCatalogEntry(
        id="hiring_signals",
        provider="nimbleway",
        display_name="Hiring Signals",
        description="Detect whether an account is hiring for specific roles and what the openings imply.",
        provider_tool_id="search",
        input_schema=[
            ToolField(key="company_url", label="Company URL or Domain", required=True, type="url"),
            ToolField(key="role_keywords", label="Role Keywords (comma-separated)", required=True),
            ToolField(key="department_keywords", label="Department Keywords", required=False),
            ToolField(key="location_keywords", label="Location Keywords", required=False),
        ],
        output_schema=[
            ToolField(key="is_hiring", label="Is Hiring", default=True),
            ToolField(key="matching_roles_count", label="Matching Roles Count", default=True),
            ToolField(key="matching_role_titles", label="Matching Role Titles", default=True),
            ToolField(key="matching_departments", label="Matching Departments", default=True),
            ToolField(key="matching_locations", label="Matching Locations", default=True),
            ToolField(key="estimated_total_open_roles", label="Estimated Total Open Roles", default=False),
            ToolField(key="requirements_summary", label="Requirements Summary", default=False),
            ToolField(key="requirements_keywords_found", label="Requirements Keywords Found", default=True),
            ToolField(key="jobs_source_url", label="Jobs Source URL", default=True),
            ToolField(key="jobs_fetch_status", label="Fetch Status", default=True),
        ],
        output_column_prefix="Hiring_",
        transform="hiring_signals_transform",
    ),
    "job_requirements": ToolCatalogEntry(
        id="job_requirements",
        provider="nimbleway",
        display_name="Job Requirements Research",
        description="Extract structured requirements, responsibilities, and stack from a job posting URL.",
        provider_tool_id="extract",
        input_schema=[
            ToolField(key="job_posting_url", label="Job Posting URL", required=True, type="url"),
        ],
        output_schema=[
            ToolField(key="job_title", label="Job Title", default=True),
            ToolField(key="responsibilities", label="Responsibilities", default=True),
            ToolField(key="requirements", label="Requirements", default=True),
            ToolField(key="tools_stack", label="Tools / Stack", default=True),
            ToolField(key="seniority", label="Seniority", default=True),
            ToolField(key="location", label="Location", default=True),
            ToolField(key="compensation", label="Compensation", default=False),
            ToolField(key="source_url", label="Source URL", default=True),
            ToolField(key="job_fetch_status", label="Fetch Status", default=True),
        ],
        output_column_prefix="Job_",
        transform="job_requirements_transform",
    ),
    "web_signals": ToolCatalogEntry(
        id="web_signals",
        provider="nimbleway",
        display_name="Web Signals",
        description="Search for company signals like hiring news, product launches, partnerships, and more.",
        provider_tool_id="search",
        input_schema=[
            ToolField(key="company", label="Company Name or Domain", required=True),
            ToolField(key="signal_preset", label="Signal Type", required=True),
            ToolField(key="country", label="Country", required=False),
        ],
        output_schema=[
            ToolField(key="signal_type", label="Signal Type", default=True),
            ToolField(key="title", label="Title", default=True),
            ToolField(key="url", label="URL", default=True),
            ToolField(key="snippet", label="Snippet", default=True),
            ToolField(key="results_count", label="Results Count", default=False),
        ],
        output_column_prefix="Signal_",
        transform="web_signals_transform",
    ),
    "extract_page": ToolCatalogEntry(
        id="extract_page",
        provider="nimbleway",
        display_name="Extract Page",
        description="Extract page title, clean text, and markdown from any URL.",
        provider_tool_id="extract",
        input_schema=[
            ToolField(key="url", label="URL", required=True, type="url"),
        ],
        output_schema=[
            ToolField(key="title", label="Title", default=True),
            ToolField(key="text", label="Text", default=True),
            ToolField(key="markdown", label="Markdown", default=False),
        ],
        output_column_prefix="Web_",
        transform="extract_page_transform",
    ),
    "search_web": ToolCatalogEntry(
        id="search_web",
        provider="nimbleway",
        display_name="Company / Web Discovery",
        description="Search for the best company result, official website, and LinkedIn company page from one query.",
        provider_tool_id="search",
        input_schema=[
            ToolField(key="query", label="Company, Domain, or Search Query", required=True),
            ToolField(key="country", label="Country", required=False),
        ],
        output_schema=[
            ToolField(key="title", label="Best Match Title", default=False),
            ToolField(key="url", label="Best Match URL", default=False),
            ToolField(key="snippet", label="Best Match Snippet", default=False),
            ToolField(key="result_domain", label="Result Domain", default=True),
            ToolField(key="official_website", label="Official Website", default=True),
            ToolField(key="linkedin_company_url", label="LinkedIn Company URL", default=True),
        ],
        output_column_prefix="Search_",
        transform="search_web_transform",
    ),
}


class NimblewayScraperProvider(ScraperProvider):
    provider_name = "nimbleway"

    def __init__(self, api_key: str | None = None, social_auth: str | None = None) -> None:
        self.api_key = api_key or ""
        self.social_auth = social_auth or None
        self._client: Any = None

    def _ensure_client(self) -> None:
        if self._client is None and self.api_key:
            self._client = NimblewayClient(api_key=self.api_key, social_auth=self.social_auth)

    def list_tools(self) -> List[ToolCatalogEntry]:
        return list(NIMBLEWAY_TOOL_CATALOG.values())

    def get_tool(self, tool_id: str) -> ToolCatalogEntry:
        tool = NIMBLEWAY_TOOL_CATALOG.get(tool_id)
        if tool is None:
            raise ValueError(f"Unknown scraper tool: {tool_id}")
        return tool

    def validate_rows(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        tool_id: str,
    ) -> Dict[str, int]:
        tool = self.get_tool(tool_id)
        valid = 0
        skipped = 0
        for row in rows:
            inputs = self.extract_inputs(row, input_mapping, tool)
            has_required_inputs = all(inputs.get(key) for key in tool.required_keys())
            is_supported = True
            if has_required_inputs and tool.id == "social_person_research":
                is_supported = _is_supported_social_profile_url(str(inputs.get("linkedin_url") or ""))
            if has_required_inputs and is_supported:
                valid += 1
            else:
                skipped += 1
        return {
            "rows_with_valid_inputs": valid,
            "rows_skipped": skipped,
            "estimated_credits": valid,
        }

    def extract_inputs(
        self,
        row: Dict[str, Any],
        input_mapping: Dict[str, str],
        tool: ToolCatalogEntry,
    ) -> Dict[str, Any]:
        return extract_inputs_from_row(row, input_mapping, tool.input_schema)

    def run_single(
        self,
        inputs: Dict[str, Any],
        tool: ToolCatalogEntry,
        output_fields: List[str],
    ) -> Dict[str, Any]:
        self._ensure_client()
        if self._client is None:
            raise ValueError("Nimbleway not connected")

        if tool.id == "extract_page":
            response = self._client.extract(url=str(inputs["url"]), formats=["html", "markdown"])
            result = NIMBLEWAY_TRANSFORMS[tool.transform](response, inputs)
        elif tool.id == "extract_company_site":
            company_url = str(inputs["company_url"])
            is_linkedin = "linkedin.com" in company_url.lower()
            response = self._client.extract(
                url=company_url,
                render=is_linkedin,
                formats=["html", "markdown"],
                request_timeout_ms=90000 if is_linkedin else 45000,
            )
            result = NIMBLEWAY_TRANSFORMS[tool.transform](response, inputs)
        elif tool.id == "social_person_research":
            profile_url = str(inputs.get("linkedin_url") or inputs.get("profile_url") or "")
            social_ref = normalize_social_profile_reference(profile_url)
            if not social_ref:
                result = _blank_social_research_result(profile_url, "unsupported_domain")
            else:
                normalized_url = social_ref.get("source_url") or profile_url
                social_inputs = dict(inputs)
                social_inputs["linkedin_url"] = normalized_url
                response: Dict[str, Any] | None = None
                social_error: NimblewayError | None = None

                try:
                    if social_ref.get("platform") == "linkedin" and self._client.has_social_api:
                        response = self._client.social_linkedin_person(str(social_ref.get("identifier") or ""))
                    elif social_ref.get("platform") == "x" and self._client.has_social_api:
                        response = self._client.social_x_profile(str(social_ref.get("identifier") or ""))
                except NimblewayError as exc:
                    social_error = exc
                    logger.warning(
                        "Nimbleway social API request failed for %s (%s): %s",
                        normalized_url,
                        social_ref.get("platform") or "unknown",
                        exc,
                    )
                    response = None

                if response is None:
                    try:
                        response = self._client.extract(
                            url=normalized_url,
                            render=social_ref.get("platform") == "linkedin",
                            formats=["html", "markdown"],
                            request_timeout_ms=90000 if social_ref.get("platform") == "linkedin" else 45000,
                        )
                    except NimblewayError as exc:
                        status = "timeout" if "timeout" in str(exc).lower() else "error"
                        error_parts: List[str] = []
                        if social_error is not None:
                            error_parts.append(f"social api failed: {social_error}")
                        error_parts.append(f"fallback extract failed: {exc}")
                        result = _blank_social_research_result(
                            normalized_url,
                            status,
                            error_message="; ".join(error_parts),
                        )
                    else:
                        result = NIMBLEWAY_TRANSFORMS[tool.transform](response, social_inputs)
                        if social_error is not None and result.get("profile_fetch_status") != "ok":
                            fallback_status = str(result.get("profile_fetch_status") or "error")
                            result["error_message"] = (
                                f"social api failed: {social_error}; "
                                f"fallback extract returned {fallback_status}"
                            )
                else:
                    result = NIMBLEWAY_TRANSFORMS[tool.transform](response, social_inputs)
        elif tool.id == "search_web":
            response = self._client.search(
                query=str(inputs["query"]),
                country=str(inputs["country"]).upper() if inputs.get("country") else None,
                max_results=5,
                deep_search=False,
                output_format="markdown",
            )
            result = NIMBLEWAY_TRANSFORMS[tool.transform](response, inputs)
        elif tool.id == "web_signals":
            preset_id = str(inputs.get("signal_preset") or "recent_news")
            preset = _WEB_SIGNAL_PRESETS.get(preset_id, _WEB_SIGNAL_PRESETS["recent_news"])
            company = str(inputs.get("company") or "")
            query = preset["query_template"].format(company=company)
            response = self._client.search(
                query=query,
                country=str(inputs["country"]).upper() if inputs.get("country") else None,
                max_results=5,
                deep_search=False,
                output_format="markdown",
            )
            result = NIMBLEWAY_TRANSFORMS[tool.transform](response, inputs)
        elif tool.id == "hiring_signals":
            domain = _candidate_domain(str(inputs.get("company_url") or ""))
            company_url = str(inputs["company_url"])
            role_kws = str(inputs.get("role_keywords") or "")
            query = f"{domain or company_url} jobs careers {role_kws}"
            dept_kws = str(inputs.get("department_keywords") or "")
            if dept_kws:
                query += f" {dept_kws}"
            search_response = self._client.search(
                query=query,
                max_results=10,
                deep_search=False,
                output_format="markdown",
            )
            extract_response = None
            search_results = search_response.get("results") or []
            if isinstance(search_results, list) and search_results:
                first_url = search_results[0].get("url") if isinstance(search_results[0], dict) else None
                if first_url:
                    try:
                        extract_response = self._client.extract(
                            url=str(first_url),
                            formats=["html", "markdown"],
                            request_timeout_ms=45000,
                        )
                    except NimblewayError:
                        pass
            result = _hiring_signals_transform(search_response, extract_response, inputs)
        elif tool.id == "job_requirements":
            job_url = str(inputs["job_posting_url"])
            response = self._client.extract(
                url=job_url,
                formats=["html", "markdown"],
                request_timeout_ms=45000,
            )
            result = NIMBLEWAY_TRANSFORMS[tool.transform](response, inputs)
        else:
            raise ValueError(f"Unsupported Nimbleway tool: {tool.id}")
        return filter_output_fields(result, output_fields)

    def run_test(
        self,
        rows: List[Dict[str, Any]],
        input_mapping: Dict[str, str],
        output_fields: List[str],
        tool_id: str,
        max_rows: int = 5,
    ) -> List[Dict[str, Any]]:
        return run_test_generic(
            self,
            rows=rows,
            input_mapping=input_mapping,
            output_fields=output_fields,
            tool_id=tool_id,
            max_rows=max_rows,
        )


SCRAPER_PROVIDER_REGISTRY.register("nimbleway", NimblewayScraperProvider)
