"""
Nimbleway SDK helpers for g-gremlin.
"""

from __future__ import annotations

import html
import json
import re
import warnings
from typing import Any, Iterable, NoReturn
from urllib.parse import quote, urlparse

import requests

from g_gremlin.contracts import CapabilityCheck, DoctorResult
from g_gremlin.credentials.provider import CredentialProvider, SecretNotFoundError

DEFAULT_TIMEOUT_SECS = 30.0
DEFAULT_MAX_RETRIES = 2
DEFAULT_DOCTOR_URL = "https://example.com"
DEFAULT_SOCIAL_API_BASE_URL = "https://api.webit.live"


class NimblewayError(RuntimeError):
    """Raised when Nimbleway operations fail."""


class NimblewayDependencyError(NimblewayError):
    """Raised when the Nimbleway SDK is not available."""


class NimblewayAuthError(NimblewayError):
    """Raised when Nimbleway credentials are rejected."""


_GENERIC_CATEGORY_VALUES = {
    "",
    "article",
    "object",
    "organization",
    "profile",
    "thing",
    "website",
    "webpage",
}


def _import_nimble_sdk():
    try:
        import nimble_python
    except ImportError as exc:
        raise NimblewayDependencyError(
            "nimble_python is not installed. Run: pip install nimble_python"
        ) from exc
    return nimble_python


def _overall_status_from_checks(checks: list[CapabilityCheck]) -> str:
    statuses = [check.status for check in checks]
    if "error" in statuses:
        return "fail"
    if "warn" in statuses:
        return "degraded"
    return "ok"


def _coerce_nimbleway_payload(payload: Any) -> Any:
    if isinstance(payload, dict):
        return {key: _coerce_nimbleway_payload(value) for key, value in payload.items()}
    elif isinstance(payload, list):
        return [_coerce_nimbleway_payload(item) for item in payload]
    elif hasattr(payload, "model_dump"):
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            return _coerce_nimbleway_payload(
                payload.model_dump(mode="json", by_alias=True, exclude_none=True)
            )
    elif hasattr(payload, "to_dict"):
        return _coerce_nimbleway_payload(payload.to_dict())
    return payload


def _model_to_dict(payload: Any) -> Any:
    result = _coerce_nimbleway_payload(payload)
    if isinstance(result, dict):
        data = result.get("data")
        if isinstance(data, dict) and isinstance(data.get("headers"), list):
            data["headers"] = "\n".join(str(item) for item in data["headers"])
    return result


def _extract_data(response: dict[str, Any]) -> dict[str, Any]:
    data = response.get("data") or {}
    return data if isinstance(data, dict) else {}


def _normalize_text(value: Any) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        return re.sub(r"\s+", " ", value).strip()
    if isinstance(value, (list, tuple)):
        for item in value:
            normalized = _normalize_text(item)
            if normalized:
                return normalized
        return ""
    if isinstance(value, dict):
        for key in ("name", "title", "text", "value", "description", "url"):
            normalized = _normalize_text(value.get(key))
            if normalized:
                return normalized
        return ""
    return re.sub(r"\s+", " ", str(value)).strip()


def _first_non_empty(*values: Any) -> str:
    for value in values:
        normalized = _normalize_text(value)
        if normalized:
            return normalized
    return ""


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


def _extract_title_from_html(html_text: str) -> str:
    match = re.search(r"<title[^>]*>(.*?)</title>", html_text or "", flags=re.IGNORECASE | re.DOTALL)
    if not match:
        return ""
    return re.sub(r"\s+", " ", html.unescape(match.group(1))).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 _html_to_text(html_text: str) -> str:
    cleaned = re.sub(r"<(script|style)\b[^>]*>.*?</\1>", " ", html_text or "", flags=re.IGNORECASE | re.DOTALL)
    cleaned = re.sub(r"<[^>]+>", " ", cleaned)
    return re.sub(r"\s+", " ", html.unescape(cleaned)).strip()


def _iter_meta_tags(html_text: str) -> Iterable[dict[str, str]]:
    for match in re.finditer(r"<meta\b[^>]*>", html_text or "", flags=re.IGNORECASE):
        tag = match.group(0)
        attrs: dict[str, str] = {}
        for attr_match in re.finditer(
            r'([a-zA-Z_:.-]+)\s*=\s*(".*?"|\'.*?\'|[^\s>]+)',
            tag,
            flags=re.DOTALL,
        ):
            key = attr_match.group(1).lower()
            raw_value = attr_match.group(2).strip()
            if len(raw_value) >= 2 and raw_value[0] in {'"', "'"} and raw_value[-1] == raw_value[0]:
                raw_value = raw_value[1:-1]
            attrs[key] = html.unescape(raw_value)
        if attrs:
            yield attrs


def _meta_lookup(html_text: str, *keys: str) -> str:
    normalized_keys = {key.lower() for key in keys}
    for attrs in _iter_meta_tags(html_text):
        name = attrs.get("name", "").lower()
        prop = attrs.get("property", "").lower()
        itemprop = attrs.get("itemprop", "").lower()
        if name in normalized_keys or prop in normalized_keys or itemprop in normalized_keys:
            return _normalize_text(attrs.get("content"))
    return ""


def _extract_canonical_url(html_text: str) -> str:
    match = re.search(
        r'<link\b(?=[^>]*\brel=["\']canonical["\'])(?=[^>]*\bhref=["\']([^"\']+)["\'])[^>]*>',
        html_text or "",
        flags=re.IGNORECASE,
    )
    if not match:
        return ""
    return _normalize_text(match.group(1))


def _iter_json_ld_objects(html_text: str) -> Iterable[dict[str, Any]]:
    for match in re.finditer(
        r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
        html_text or "",
        flags=re.IGNORECASE | re.DOTALL,
    ):
        raw = html.unescape(match.group(1)).strip()
        if not raw:
            continue
        try:
            payload = json.loads(raw)
        except Exception:
            continue
        yield from _flatten_json_ld(payload)


def _flatten_json_ld(payload: Any) -> Iterable[dict[str, Any]]:
    if isinstance(payload, list):
        for item in payload:
            yield from _flatten_json_ld(item)
        return
    if not isinstance(payload, dict):
        return
    graph = payload.get("@graph")
    if isinstance(graph, list):
        for item in graph:
            yield from _flatten_json_ld(item)
    yield payload


def _extract_parsing_entities(data: dict[str, Any]) -> dict[str, Any]:
    parsing = data.get("parsing")
    if not isinstance(parsing, dict):
        return {}
    entities = parsing.get("entities")
    if isinstance(entities, dict):
        return entities
    return parsing


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 _clean_company_name(value: str, source_url: str | None = None) -> str:
    candidate = _normalize_text(value)
    if not candidate:
        return ""
    parts = re.split(r"\s*[|•·]\s*|\s+-\s+|\s+—\s+|\s+–\s+", candidate)
    source_domain = _candidate_domain(source_url)
    noise_tokens = {
        "linkedin",
        "linkedin.com",
        "facebook",
        "instagram",
        "x",
        "twitter",
        "youtube",
    }
    if source_domain:
        noise_tokens.add(source_domain)
    filtered = [
        part.strip()
        for part in parts
        if part.strip() and part.strip().lower() not in noise_tokens
    ]
    if filtered:
        candidate = filtered[0]
    return candidate.strip(" -|•·")


def _extract_first_sentence(text: str, max_chars: int = 320) -> str:
    candidate = _normalize_text(text)
    if not candidate:
        return ""
    sentence_match = re.match(r"(.+?[.!?])(?:\s|$)", candidate)
    if sentence_match:
        sentence = sentence_match.group(1).strip()
        if len(sentence) <= max_chars:
            return sentence
    return candidate[:max_chars].rstrip()


def _extract_markdown_section(markdown: str, heading: str) -> str:
    match = re.search(
        rf"(?:^|\n)#+\s*{re.escape(heading)}\s*\n+(.*?)(?=\n#+\s+|\Z)",
        markdown or "",
        flags=re.IGNORECASE | re.DOTALL,
    )
    if not match:
        return ""
    return _normalize_text(match.group(1))


def _extract_follower_count(*texts: str) -> str:
    for text in texts:
        match = re.search(r"(\d[\d,\.]*)\+?\s+followers?\b", text or "", flags=re.IGNORECASE)
        if match:
            return match.group(1)
    return ""


def _is_linkedin_company_url(source_url: str | None) -> bool:
    host = _candidate_domain(source_url)
    path = urlparse(str(source_url or "")).path.strip("/")
    return host == "linkedin.com" and (
        path.startswith("company/") or
        path.startswith("school/") or
        path.startswith("showcase/")
    )


def _extract_linkedin_company_url(
    html_text: str,
    *,
    source_url: str | None = None,
    entities: dict[str, Any] | None = None,
    org_like: dict[str, Any] | None = None,
) -> str:
    if _is_linkedin_company_url(source_url):
        return _normalize_text(source_url)

    candidate_values: list[Any] = []
    if entities:
        candidate_values.extend(
            [
                entities.get("linkedin_url"),
                entities.get("linkedin"),
                entities.get("social_urls"),
                entities.get("profiles"),
            ]
        )
    if org_like:
        candidate_values.extend(
            [
                org_like.get("sameAs"),
                org_like.get("url"),
            ]
        )

    for candidate in candidate_values:
        values = [candidate] if not isinstance(candidate, (list, tuple, set)) else list(candidate)
        for value in values:
            normalized = _normalize_text(value)
            if _is_linkedin_company_url(normalized):
                return normalized

    regex_match = re.search(
        r"https?://(?:www\.)?linkedin\.com/(?:company|school|showcase)/[A-Za-z0-9._%\-]+/?",
        html_text or "",
        flags=re.IGNORECASE,
    )
    if regex_match:
        return _normalize_text(regex_match.group(0))
    return ""


def _format_location_parts(*parts: Any) -> str:
    seen: list[str] = []
    for part in parts:
        normalized = _normalize_text(part)
        if not normalized or normalized in seen:
            continue
        seen.append(normalized)
    return ", ".join(seen)


def _extract_hq_location(entities: dict[str, Any], org_like: dict[str, Any]) -> str:
    direct = _first_non_empty(
        entities.get("hq_location"),
        entities.get("headquarters"),
        entities.get("location"),
        entities.get("address"),
    )
    if direct:
        return direct

    address = org_like.get("address") if isinstance(org_like, dict) else None
    if isinstance(address, list):
        for item in address:
            if not isinstance(item, dict):
                continue
            formatted = _format_location_parts(
                item.get("addressLocality"),
                item.get("addressRegion"),
                item.get("addressCountry"),
            )
            if formatted:
                return formatted
    if isinstance(address, dict):
        formatted = _format_location_parts(
            address.get("addressLocality"),
            address.get("addressRegion"),
            address.get("addressCountry"),
        )
        if formatted:
            return formatted

    return ""


def _person_name_from_url(source_url: str | None) -> str:
    path = urlparse(str(source_url or "")).path.strip("/")
    if not path.startswith("in/"):
        return ""
    slug = path.split("/", 1)[-1].strip("/")
    if not slug:
        return ""
    cleaned = re.sub(r"[-_]+", " ", slug)
    cleaned = re.sub(r"\d+$", "", cleaned).strip()
    return cleaned.title()


def _is_linkedin_profile_url(source_url: str | None) -> bool:
    host = _candidate_domain(source_url)
    path = urlparse(str(source_url or "")).path.strip("/")
    return host == "linkedin.com" and path.startswith("in/")


def _extract_linkedin_title_parts(html_text: str, markdown: str, source_url: str | None) -> tuple[str, str]:
    raw_title = _first_non_empty(
        _meta_lookup(html_text, "og:title", "twitter:title"),
        _extract_title_from_html(html_text),
        _extract_title_from_markdown(markdown),
    )
    if not raw_title:
        return _person_name_from_url(source_url), ""
    cleaned = re.sub(r"\s*\|\s*LinkedIn\s*$", "", raw_title, flags=re.IGNORECASE).strip()
    cleaned = re.sub(r"\s+", " ", cleaned)
    if " - " in cleaned:
        name, headline = cleaned.split(" - ", 1)
        return _normalize_text(name), _normalize_text(headline)
    return _normalize_text(cleaned), ""


def _split_role_company_from_headline(headline: str) -> tuple[str, str]:
    cleaned = _normalize_text(headline)
    if not cleaned:
        return "", ""
    if re.search(r"\s+at\s+", cleaned, flags=re.IGNORECASE):
        parts = re.split(r"\s+at\s+", cleaned, maxsplit=1, flags=re.IGNORECASE)
        return _normalize_text(parts[0]), _normalize_text(parts[1])
    primary = re.split(r"\s+[|•·]\s+|\.\s+", cleaned, maxsplit=1)[0].strip()
    comma_parts = [part.strip() for part in primary.split(",") if part.strip()]
    if len(comma_parts) >= 2:
        return comma_parts[0], comma_parts[1]
    return primary, ""


def _extract_location_from_html(html_text: str) -> str:
    patterns = [
        r'top-card-layout__first-subline[^>]*>(.*?)</span>',
        r'profile-topcard__location-data[^>]*>(.*?)</span>',
        r'profile__location[^>]*>(.*?)</span>',
    ]
    for pattern in patterns:
        match = re.search(pattern, html_text or "", flags=re.IGNORECASE | re.DOTALL)
        if not match:
            continue
        location = _html_to_text(match.group(1))
        location = re.sub(r"\b\d+\+?\s+followers?\b", "", location, flags=re.IGNORECASE)
        location = re.sub(r"\b\d+\+?\s+connections?\b", "", location, flags=re.IGNORECASE)
        normalized = _normalize_text(location)
        if normalized:
            return normalized
    return ""


def _is_x_profile_url(source_url: str | None) -> bool:
    host = _candidate_domain(source_url)
    segments = [part for part in urlparse(str(source_url or "")).path.strip("/").split("/") if part]
    if host not in {"x.com", "twitter.com"} or not segments:
        return False
    first_segment = segments[0].lower()
    blocked_segments = {
        "explore",
        "hashtag",
        "home",
        "i",
        "intent",
        "messages",
        "notifications",
        "search",
        "settings",
        "share",
        "status",
    }
    if first_segment in blocked_segments:
        return False
    if len(segments) >= 2 and segments[1].lower() == "status":
        return False
    return True


def _canonical_social_profile_url(platform: str, identifier: str) -> str:
    slug = _normalize_text(identifier).lstrip("@").strip("/")
    if not slug:
        return ""
    if platform == "linkedin":
        return f"https://www.linkedin.com/in/{slug}/"
    if platform == "x":
        return f"https://x.com/{slug}"
    return ""


def normalize_social_profile_reference(profile_ref: str | None) -> dict[str, str]:
    raw_value = _normalize_text(profile_ref)
    if not raw_value:
        return {}

    if raw_value.startswith("@"):
        identifier = raw_value.lstrip("@")
        return {
            "platform": "x",
            "identifier": identifier,
            "source_url": _canonical_social_profile_url("x", identifier),
        }

    candidate = raw_value
    if "://" not in candidate and re.match(r"^(?:www\.)?(?:linkedin\.com|x\.com|twitter\.com)/", candidate, flags=re.IGNORECASE):
        candidate = f"https://{candidate}"

    host = _candidate_domain(candidate)
    path = urlparse(candidate).path.strip("/")

    if host == "linkedin.com" and path.startswith("in/"):
        parts = [part for part in path.split("/") if part]
        if len(parts) >= 2 and parts[1]:
            identifier = parts[1]
            return {
                "platform": "linkedin",
                "identifier": identifier,
                "source_url": _canonical_social_profile_url("linkedin", identifier),
            }

    if host in {"x.com", "twitter.com"} and path:
        first_segment = path.split("/", 1)[0]
        if _is_x_profile_url(candidate) and first_segment:
            identifier = first_segment.lstrip("@")
            return {
                "platform": "x",
                "identifier": identifier,
                "source_url": _canonical_social_profile_url("x", identifier),
            }

    return {}


def normalize_linkedin_person_reference(profile_ref: str | None) -> dict[str, str]:
    normalized = normalize_social_profile_reference(profile_ref)
    if normalized.get("platform") == "linkedin":
        return normalized

    raw_value = _normalize_text(profile_ref)
    if not raw_value:
        return {}
    if "://" in raw_value or "/" in raw_value or " " in raw_value or raw_value.startswith("@"):
        return {}
    if not re.match(r"^[A-Za-z0-9._%\-]+$", raw_value):
        return {}
    return {
        "platform": "linkedin",
        "identifier": raw_value,
        "source_url": _canonical_social_profile_url("linkedin", raw_value),
    }


def _social_meta(response: dict[str, Any]) -> dict[str, Any]:
    meta = response.get("meta") or {}
    return meta if isinstance(meta, dict) else {}


def _is_social_api_response(response: dict[str, Any]) -> bool:
    return _normalize_text(_social_meta(response).get("transport")).lower() == "social_api"


def _find_linkedin_current_position(data: dict[str, Any]) -> tuple[str, str]:
    fallback_role = ""
    fallback_company = ""
    experience = data.get("experience")
    if isinstance(experience, list):
        for item in experience:
            if not isinstance(item, dict):
                continue
            company = item.get("company")
            company_name = _normalize_text(company.get("name")) if isinstance(company, dict) else ""
            positions = item.get("positions")
            if not isinstance(positions, list):
                continue
            for position in positions:
                if not isinstance(position, dict):
                    continue
                role = _normalize_text(position.get("role"))
                position_company = _normalize_text(position.get("company_name"))
                resolved_company = _first_non_empty(position_company, company_name)
                if not fallback_role and (role or resolved_company):
                    fallback_role, fallback_company = role, resolved_company
                if position.get("is_current_position"):
                    return role, resolved_company

    top_position = data.get("top_position")
    if isinstance(top_position, dict):
        company_info = top_position.get("company_info")
        top_company = _normalize_text(company_info.get("name")) if isinstance(company_info, dict) else ""
        if fallback_role or top_company:
            return fallback_role, _first_non_empty(fallback_company, top_company)
        if top_company:
            return "", top_company

    return fallback_role, fallback_company


def _extract_social_linkedin_profile(data: dict[str, Any], source_url: str | None = None) -> dict[str, str]:
    name_parts = [
        _normalize_text(data.get("first_name")),
        _normalize_text(data.get("middle_name")),
        _normalize_text(data.get("last_name")),
    ]
    person_name = _first_non_empty(" ".join(part for part in name_parts if part), data.get("name"))
    resolved_source_url = _first_non_empty(
        data.get("url"),
        _canonical_social_profile_url("linkedin", _normalize_text(data.get("public_identifier"))),
        source_url,
    )
    headline = _normalize_text(data.get("headline"))
    about = _normalize_text(data.get("about"))
    current_role, current_company = _find_linkedin_current_position(data)
    if (not current_role or not current_company) and headline:
        headline_role, headline_company = _split_role_company_from_headline(headline)
        current_role = _first_non_empty(current_role, headline_role)
        current_company = _first_non_empty(current_company, headline_company)

    location = ""
    location_data = data.get("location")
    if isinstance(location_data, dict):
        location = _normalize_text(location_data.get("name"))
    else:
        location = _normalize_text(location_data)

    return {
        "person_name": _first_non_empty(person_name, _person_name_from_url(resolved_source_url or source_url)),
        "headline_or_bio": _first_non_empty(headline, about),
        "current_role": current_role,
        "current_company": current_company,
        "location": location,
        "source_type": "linkedin",
        "source_url": _normalize_text(resolved_source_url or source_url),
    }


def _extract_social_x_profile(data: dict[str, Any], source_url: str | None = None) -> dict[str, str]:
    screen_name = _normalize_text(data.get("screen_name") or data.get("username"))
    resolved_source_url = _first_non_empty(
        data.get("url"),
        _canonical_social_profile_url("x", screen_name),
        source_url,
    )
    return {
        "person_name": _first_non_empty(data.get("name"), screen_name),
        "headline_or_bio": _normalize_text(data.get("description")),
        "current_role": "",
        "current_company": "",
        "location": _normalize_text(data.get("location")),
        "source_type": "x",
        "source_url": _normalize_text(resolved_source_url or source_url),
    }


def _select_best_website(source_url: str | None, *candidates: Any) -> str:
    source = _normalize_text(source_url)
    for candidate in candidates:
        normalized = _normalize_text(candidate)
        if not normalized:
            continue
        if "linkedin.com" in normalized.lower():
            continue
        return normalized
    if source and "linkedin.com" not in source.lower():
        return source
    return ""


def _select_category(*candidates: Any) -> str:
    for candidate in candidates:
        normalized = _normalize_text(candidate)
        if not normalized:
            continue
        if normalized.lower() in _GENERIC_CATEGORY_VALUES:
            continue
        return normalized
    return ""


def extract_company_profile(
    response: dict[str, Any],
    *,
    source_url: str | None = None,
) -> dict[str, str]:
    """Derive a structured company profile from a Nimbleway extract response."""
    data = _extract_data(response)
    html_text = str(data.get("html") or "")
    markdown = str(data.get("markdown") or "")
    page_text = _markdown_to_text(markdown) or _html_to_text(html_text)
    title = _clean_company_name(
        _first_non_empty(
            _extract_title_from_markdown(markdown),
            _extract_title_from_html(html_text),
        ),
        source_url=source_url,
    )

    entities = _extract_parsing_entities(data)
    json_ld_objects = list(_iter_json_ld_objects(html_text))
    org_like = next(
        (
            item for item in json_ld_objects
            if _normalize_text(item.get("@type")).lower()
            in {"organization", "corporation", "localbusiness", "softwareapplication", "brand"}
        ),
        json_ld_objects[0] if json_ld_objects else {},
    )

    company_name = _clean_company_name(
        _first_non_empty(
            entities.get("company_name"),
            entities.get("name"),
            entities.get("company"),
            org_like.get("name") if isinstance(org_like, dict) else None,
            _meta_lookup(html_text, "og:site_name", "application-name", "twitter:title"),
            title,
            _candidate_domain(source_url),
        ),
        source_url=source_url,
    )
    description = _first_non_empty(
        entities.get("description"),
        entities.get("summary"),
        entities.get("about"),
        entities.get("tagline"),
        org_like.get("description") if isinstance(org_like, dict) else None,
        _meta_lookup(html_text, "description", "og:description", "twitter:description"),
        _extract_first_sentence(page_text),
    )
    category = _select_category(
        entities.get("category"),
        entities.get("industry"),
        entities.get("vertical"),
        org_like.get("industry") if isinstance(org_like, dict) else None,
        org_like.get("@type") if isinstance(org_like, dict) else None,
        _meta_lookup(html_text, "category", "og:type"),
    )
    website = _select_best_website(
        source_url,
        entities.get("website"),
        entities.get("url"),
        org_like.get("url") if isinstance(org_like, dict) else None,
        _meta_lookup(html_text, "og:url"),
    )
    linkedin_url = _extract_linkedin_company_url(
        html_text,
        source_url=source_url,
        entities=entities,
        org_like=org_like if isinstance(org_like, dict) else {},
    )
    follower_count = _first_non_empty(
        entities.get("follower_count"),
        entities.get("followers"),
        _extract_follower_count(
            description,
            page_text,
            markdown,
            html_text,
        ),
    )
    hq_location = _extract_hq_location(entities, org_like if isinstance(org_like, dict) else {})

    return {
        "company_name": company_name,
        "description": description,
        "category": category,
        "website": website,
        "linkedin_url": linkedin_url,
        "follower_count": follower_count,
        "hq_location": hq_location,
    }


def extract_person_profile(
    response: dict[str, Any],
    *,
    source_url: str | None = None,
) -> dict[str, str]:
    """Derive a structured public-profile summary from Nimbleway extract or social responses."""
    if _is_social_api_response(response):
        data = _extract_data(response)
        platform = _normalize_text(_social_meta(response).get("platform")).lower()
        if platform == "linkedin":
            return _extract_social_linkedin_profile(data, source_url=source_url)
        if platform == "x":
            return _extract_social_x_profile(data, source_url=source_url)

    data = _extract_data(response)
    html_text = str(data.get("html") or "")
    markdown = str(data.get("markdown") or "")
    page_text = _markdown_to_text(markdown) or _html_to_text(html_text)
    entities = _extract_parsing_entities(data)
    resolved_source_url = _first_non_empty(
        _extract_canonical_url(html_text),
        _meta_lookup(html_text, "og:url"),
        source_url,
    )
    person_name, headline = _extract_linkedin_title_parts(html_text, markdown, resolved_source_url or source_url)
    if not person_name:
        person_name = _first_non_empty(
            entities.get("full_name"),
            entities.get("person_name"),
            entities.get("name"),
            _person_name_from_url(resolved_source_url or source_url),
        )

    about = _extract_markdown_section(markdown, "About")
    headline_or_bio = _first_non_empty(
        headline,
        about,
        _meta_lookup(html_text, "description", "og:description", "twitter:description"),
        _extract_first_sentence(page_text),
    )
    current_role, current_company = _split_role_company_from_headline(headline)
    location = _first_non_empty(
        entities.get("location"),
        _extract_location_from_html(html_text),
        _meta_lookup(html_text, "profile:location", "og:locality"),
    )
    source_type = "linkedin" if _is_linkedin_profile_url(resolved_source_url or source_url) else ""

    return {
        "person_name": person_name,
        "headline_or_bio": headline_or_bio,
        "current_role": current_role,
        "current_company": current_company,
        "location": location,
        "source_type": source_type,
        "source_url": _normalize_text(resolved_source_url or source_url),
    }


def get_nimbleway_api_key(
    credential_provider: CredentialProvider | None = None,
    profile: str = "default",
) -> str:
    provider = credential_provider or CredentialProvider()
    try:
        secret = provider.get_secret("nimbleway", "api_key", profile=profile)
    except SecretNotFoundError as exc:
        raise NimblewayError(
            "Nimbleway is not configured. Set NIMBLEWAY_API_KEY or NIMBLE_API_KEY, "
            "or run: g-gremlin auth set nimbleway --key api_key --value <key>"
        ) from exc
    return secret.value


def get_nimbleway_social_auth(
    credential_provider: CredentialProvider | None = None,
    profile: str = "default",
) -> str | None:
    provider = credential_provider or CredentialProvider()
    secret = provider.get_secret_or_none("nimbleway", "social_auth", profile=profile)
    if secret is None:
        return None
    return secret.value


class NimblewayClient:
    """Thin wrapper over the Nimbleway SDK."""

    def __init__(
        self,
        api_key: str | None = None,
        *,
        social_auth: str | None = None,
        credential_provider: CredentialProvider | None = None,
        profile: str = "default",
        timeout: float = DEFAULT_TIMEOUT_SECS,
        max_retries: int = DEFAULT_MAX_RETRIES,
    ) -> None:
        self._sdk = _import_nimble_sdk()
        self._credential_provider = credential_provider or CredentialProvider()
        self.api_key = api_key or get_nimbleway_api_key(
            credential_provider=self._credential_provider,
            profile=profile,
        )
        configured_social_auth = (
            social_auth
            if social_auth is not None
            else get_nimbleway_social_auth(
                credential_provider=self._credential_provider,
                profile=profile,
            )
        )
        # Nimble's primary API key is valid for Social API requests unless an
        # explicit social credential override is configured.
        self.social_auth = configured_social_auth or self.api_key
        self._social_base_url = DEFAULT_SOCIAL_API_BASE_URL.rstrip("/")
        self._social_timeout = timeout
        self._client = self._sdk.Nimble(
            api_key=self.api_key,
            timeout=timeout,
            max_retries=max_retries,
        )

    @property
    def sdk_version(self) -> str:
        return str(getattr(self._sdk, "__version__", "unknown"))

    @property
    def has_social_api(self) -> bool:
        return bool(self.social_auth)

    def extract(
        self,
        *,
        url: str,
        country: str | None = None,
        render: bool = False,
        parse: bool = False,
        parser: str | dict[str, Any] | None = None,
        formats: Iterable[str] | None = None,
        request_timeout_ms: int | None = None,
        skill: str | list[str] | None = None,
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {"url": url}
        if country:
            kwargs["country"] = str(country).upper()
        if render:
            kwargs["render"] = True
        if parse:
            kwargs["parse"] = True
        if parser is not None:
            kwargs["parser"] = parser
        if formats:
            kwargs["formats"] = list(formats)
        if request_timeout_ms is not None:
            kwargs["request_timeout"] = int(request_timeout_ms)
        if skill:
            kwargs["skill"] = skill

        try:
            response = self._client.extract(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def social_linkedin_person(self, identifier: str) -> dict[str, Any]:
        return self._social_get(
            f"/api/v1/realtime/social/linkedin/v1/profile/{quote(str(identifier).strip('/'))}",
            platform="linkedin",
        )

    def social_x_profile(self, screen_name: str) -> dict[str, Any]:
        normalized = str(screen_name).strip().lstrip("@").strip("/")
        return self._social_get(
            f"/api/v1/realtime/social/twitter/v1/profile/{quote(normalized)}/details",
            platform="x",
        )

    def search(
        self,
        *,
        query: str,
        country: str | None = None,
        max_results: int = 10,
        deep_search: bool | None = None,
        search_depth: str | None = None,
        focus: str | list[str] | None = None,
        content_type: list[str] | None = None,
        include_domains: list[str] | None = None,
        exclude_domains: list[str] | None = None,
        locale: str | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
        time_range: str | None = None,
        max_subagents: int | None = None,
        include_answer: bool = False,
        output_format: str = "plain_text",
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {
            "query": query,
            "max_results": max_results,
            "output_format": output_format,
        }
        normalized_search_depth = _normalize_text(search_depth).lower()
        if normalized_search_depth:
            kwargs["search_depth"] = normalized_search_depth
        elif deep_search is not None:
            kwargs["deep_search"] = bool(deep_search)
        if country:
            kwargs["country"] = str(country).upper()
        if focus:
            kwargs["focus"] = focus
        if content_type:
            kwargs["content_type"] = content_type
        if include_domains:
            kwargs["include_domains"] = include_domains
        if exclude_domains:
            kwargs["exclude_domains"] = exclude_domains
        if locale:
            kwargs["locale"] = str(locale)
        if start_date:
            kwargs["start_date"] = str(start_date)
        if end_date:
            kwargs["end_date"] = str(end_date)
        if time_range:
            kwargs["time_range"] = str(time_range).lower()
        if max_subagents is not None:
            kwargs["max_subagents"] = int(max_subagents)
        if include_answer:
            kwargs["include_answer"] = True

        try:
            response = self._client.search(**kwargs)
        except TypeError as exc:
            if "search_depth" not in kwargs or "search_depth" not in str(exc):
                self._raise_nimbleway_error(exc)
            legacy_kwargs = dict(kwargs)
            legacy_search_depth = str(legacy_kwargs.pop("search_depth") or "").lower()
            legacy_kwargs["deep_search"] = legacy_search_depth == "deep"
            try:
                response = self._client.search(**legacy_kwargs)
            except Exception as legacy_exc:  # noqa: BLE001
                self._raise_nimbleway_error(legacy_exc)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def map(
        self,
        *,
        url: str,
        country: str | None = None,
        domain_filter: str | None = None,
        limit: int | None = None,
        locale: str | None = None,
        sitemap: str | None = None,
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {"url": url}
        if country:
            kwargs["country"] = str(country).upper()
        if domain_filter:
            kwargs["domain_filter"] = str(domain_filter).lower()
        if limit is not None:
            kwargs["limit"] = int(limit)
        if locale:
            kwargs["locale"] = str(locale)
        if sitemap:
            kwargs["sitemap"] = str(sitemap).lower()

        try:
            response = self._client.map(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def tasks_list(
        self,
        *,
        cursor: str | None = None,
        limit: int | None = None,
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {}
        if cursor:
            kwargs["cursor"] = str(cursor)
        if limit is not None:
            kwargs["limit"] = int(limit)

        try:
            response = self._client.tasks.list(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def task_get(self, task_id: str) -> dict[str, Any]:
        try:
            response = self._client.tasks.get(str(task_id))
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def task_results(self, task_id: str) -> dict[str, Any]:
        try:
            response = self._client.tasks.results(str(task_id))
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def agent_list(
        self,
        *,
        limit: int | None = None,
        managed_by: str | None = None,
        offset: int | None = None,
        privacy: str | None = None,
        search: str | None = None,
    ) -> list[dict[str, Any]]:
        kwargs: dict[str, Any] = {}
        if limit is not None:
            kwargs["limit"] = int(limit)
        if managed_by:
            kwargs["managed_by"] = str(managed_by).lower()
        if offset is not None:
            kwargs["offset"] = int(offset)
        if privacy:
            kwargs["privacy"] = str(privacy).lower()

        extra_query = {"search": str(search)} if search else None
        try:
            if extra_query:
                response = self._client.agent.list(extra_query=extra_query, **kwargs)
            else:
                response = self._client.agent.list(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def agent_get(self, template_name: str) -> dict[str, Any]:
        try:
            response = self._client.agent.get(str(template_name))
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def agent_run(
        self,
        *,
        agent: str,
        params: dict[str, Any],
        localization: bool = False,
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {
            "agent": str(agent),
            "params": params,
        }
        if localization:
            kwargs["localization"] = True

        try:
            response = self._client.agent.run(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def agent_run_async(
        self,
        *,
        agent: str,
        params: dict[str, Any],
        callback_url: str | None = None,
        localization: bool = False,
        storage_compress: bool = False,
        storage_object_name: str | None = None,
        storage_type: str | None = None,
        storage_url: str | None = None,
    ) -> dict[str, Any]:
        kwargs: dict[str, Any] = {
            "agent": str(agent),
            "params": params,
        }
        if callback_url:
            kwargs["callback_url"] = str(callback_url)
        if localization:
            kwargs["localization"] = True
        if storage_compress:
            kwargs["storage_compress"] = True
        if storage_object_name:
            kwargs["storage_object_name"] = str(storage_object_name)
        if storage_type:
            kwargs["storage_type"] = str(storage_type)
        if storage_url:
            kwargs["storage_url"] = str(storage_url)

        try:
            response = self._client.agent.run_async(**kwargs)
        except Exception as exc:  # noqa: BLE001
            self._raise_nimbleway_error(exc)
        return _model_to_dict(response)

    def _social_get(self, path: str, *, platform: str) -> dict[str, Any]:
        if not self.social_auth:
            raise NimblewayError(
                "Nimbleway social auth is not configured. Set NIMBLEWAY_SOCIAL_AUTH "
                "or run: g-gremlin auth set nimbleway --key social_auth --value <credential>"
            )

        url = f"{self._social_base_url}{path}"
        response = None
        for auth_header in self._format_social_auth_headers(self.social_auth):
            headers = {"Authorization": auth_header}
            try:
                candidate = requests.get(url, headers=headers, timeout=self._social_timeout)
            except requests.Timeout as exc:
                raise NimblewayError(f"Nimbleway social timeout: {exc}") from exc
            except requests.RequestException as exc:
                raise NimblewayError(f"Nimbleway social connection error: {exc}") from exc

            response = candidate
            if candidate.status_code not in {401, 403}:
                break

        if response.status_code in {401, 403}:
            raise NimblewayAuthError("Invalid Nimbleway social auth")
        if response.status_code == 404:
            return {
                "status": "not_found",
                "status_code": 404,
                "data": {},
                "meta": {"transport": "social_api", "platform": platform},
            }
        if response.status_code >= 400:
            detail = ""
            try:
                detail = _normalize_text(response.json())
            except ValueError:
                detail = _normalize_text(response.text)
            message = f"Nimbleway social API error ({response.status_code})"
            if detail:
                message = f"{message}: {detail}"
            raise NimblewayError(message)

        try:
            payload = response.json()
        except ValueError as exc:
            raise NimblewayError("Nimbleway social API returned invalid JSON") from exc

        if not isinstance(payload, dict):
            raise NimblewayError(f"Unexpected Nimbleway social response type: {type(payload)!r}")

        return {
            "status": "success",
            "status_code": response.status_code,
            "data": payload,
            "meta": {"transport": "social_api", "platform": platform},
        }

    @staticmethod
    def _format_social_auth_headers(credential: str) -> list[str]:
        value = str(credential or "").strip()
        if not value:
            return []

        lower_value = value.lower()
        if lower_value.startswith(("basic ", "bearer ")):
            return [value]

        # Nimble's standard API keys authenticate successfully against Social
        # API as Bearer tokens. Keep Basic as a compatibility fallback for any
        # legacy Social-specific credentials.
        return [f"Bearer {value}", f"Basic {value}"]

    def _raise_nimbleway_error(self, exc: Exception) -> NoReturn:
        auth_error = getattr(self._sdk, "AuthenticationError", ())
        permission_error = getattr(self._sdk, "PermissionDeniedError", ())
        rate_limit_error = getattr(self._sdk, "RateLimitError", ())
        status_error = getattr(self._sdk, "APIStatusError", ())
        connection_error = getattr(self._sdk, "APIConnectionError", ())
        timeout_error = getattr(self._sdk, "APITimeoutError", ())

        if auth_error and isinstance(exc, auth_error):
            raise NimblewayAuthError(str(exc)) from exc
        if permission_error and isinstance(exc, permission_error):
            raise NimblewayAuthError(str(exc)) from exc
        if rate_limit_error and isinstance(exc, rate_limit_error):
            raise NimblewayError(f"Nimbleway rate limit: {exc}") from exc
        if connection_error and isinstance(exc, connection_error):
            raise NimblewayError(f"Nimbleway connection error: {exc}") from exc
        if timeout_error and isinstance(exc, timeout_error):
            raise NimblewayError(f"Nimbleway timeout: {exc}") from exc
        if status_error and isinstance(exc, status_error):
            raise NimblewayError(str(exc)) from exc
        raise NimblewayError(str(exc)) from exc


def get_primary_extract_payload(response: dict[str, Any]) -> Any:
    data = response.get("data") or {}
    if not isinstance(data, dict):
        return data
    parsing = data.get("parsing")
    if parsing:
        return parsing
    markdown = data.get("markdown")
    if markdown:
        return markdown
    html = data.get("html")
    if html:
        return html
    return data


def run_nimbleway_doctor(
    *,
    test_url: str = DEFAULT_DOCTOR_URL,
    country: str | None = None,
    credential_provider: CredentialProvider | None = None,
    profile: str = "default",
) -> DoctorResult:
    provider = credential_provider or CredentialProvider()
    checks: list[CapabilityCheck] = []
    identity: dict[str, Any] = {
        "probe_url": test_url,
        "profile": profile,
    }

    secret = provider.get_secret_or_none("nimbleway", "api_key", profile=profile)
    if secret is None:
        checks.append(CapabilityCheck("api_key", "error", "not configured"))
        return DoctorResult(
            provider="nimbleway",
            checks=checks,
            identity=identity,
            overall_status="fail",
        )

    identity["credential_source"] = secret.source.value

    try:
        sdk = _import_nimble_sdk()
        identity["sdk_version"] = str(getattr(sdk, "__version__", "unknown"))
        checks.append(CapabilityCheck("sdk", "ok", identity["sdk_version"]))
    except NimblewayDependencyError as exc:
        checks.append(CapabilityCheck("sdk", "error", str(exc)))
        return DoctorResult(
            provider="nimbleway",
            checks=checks,
            identity=identity,
            overall_status="fail",
        )

    checks.append(CapabilityCheck("api_key", "ok", identity["credential_source"]))

    try:
        client = NimblewayClient(
            api_key=secret.value,
            credential_provider=provider,
            profile=profile,
        )
        response = client.extract(
            url=test_url,
            country=country,
            render=False,
            formats=["markdown"],
        )
        task_status = str(response.get("status") or "unknown").lower()
        status_code = response.get("status_code")
        identity["task_id"] = response.get("task_id")
        identity["final_url"] = response.get("url")
        identity["status_code"] = status_code
        if task_status == "success":
            check_status = "ok"
        elif task_status in {"skipped", "postponed", "ignored"}:
            check_status = "warn"
        else:
            check_status = "error"
        detail = f"status={task_status}"
        if status_code is not None:
            detail = f"{detail} http={status_code}"
        checks.append(CapabilityCheck("extract_ping", check_status, detail))
    except NimblewayAuthError as exc:
        checks.append(CapabilityCheck("extract_ping", "error", str(exc)))
    except NimblewayError as exc:
        checks.append(CapabilityCheck("extract_ping", "error", str(exc)))

    return DoctorResult(
        provider="nimbleway",
        checks=checks,
        identity=identity,
        overall_status=_overall_status_from_checks(checks),
    )
