use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;

/// Supported output formats.
///
/// `"extract"` and `"llm-extract"` are accepted as aliases for `Json`
/// during deserialization (Firecrawl compatibility).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum OutputFormat {
    Markdown,
    Html,
    RawHtml,
    PlainText,
    Links,
    Json,
    Summary,
}

impl<'de> Deserialize<'de> for OutputFormat {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.as_str() {
            "markdown" => Ok(OutputFormat::Markdown),
            "html" => Ok(OutputFormat::Html),
            "rawHtml" => Ok(OutputFormat::RawHtml),
            "plainText" => Ok(OutputFormat::PlainText),
            "links" => Ok(OutputFormat::Links),
            "json" | "extract" | "llm-extract" => Ok(OutputFormat::Json),
            "summary" => Ok(OutputFormat::Summary),
            other => Err(serde::de::Error::custom(format!(
                "Unknown format '{other}'. Valid formats: markdown, html, rawHtml, plainText, links, json, summary \
                 (aliases: extract, llm-extract). Use formats: [\"json\"] with jsonSchema for structured extraction."
            ))),
        }
    }
}

/// Strategy for chunking text content.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ChunkStrategy {
    /// Split on sentence boundaries (.!?). Merges short chunks up to max_chars.
    #[serde(rename = "sentence")]
    Sentence {
        #[serde(default, alias = "maxChars")]
        max_chars: Option<usize>,
        #[serde(default, alias = "overlapChars")]
        overlap_chars: Option<usize>,
        #[serde(default)]
        dedupe: Option<bool>,
    },
    /// Split on a regex pattern.
    #[serde(rename = "regex")]
    Regex {
        pattern: String,
        #[serde(default, alias = "maxChars")]
        max_chars: Option<usize>,
        #[serde(default, alias = "overlapChars")]
        overlap_chars: Option<usize>,
        #[serde(default)]
        dedupe: Option<bool>,
    },
    /// Split on markdown headings (h1-h6).
    #[serde(rename = "topic")]
    Topic {
        #[serde(default, alias = "maxChars")]
        max_chars: Option<usize>,
        #[serde(default, alias = "overlapChars")]
        overlap_chars: Option<usize>,
        #[serde(default)]
        dedupe: Option<bool>,
    },
}

/// Filtering mode for ranked chunk retrieval.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FilterMode {
    Bm25,
    Cosine,
}

/// Per-request renderer override. Sibling to `renderJs` for finer control.
///
/// `Auto` is equivalent to omitting the field — uses the configured fallback chain.
/// Other variants hard-pin to a specific renderer with no fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RequestedRenderer {
    Auto,
    Lightpanda,
    Chrome,
    /// Residential-proxy Chrome tier — egresses through the DataImpulse
    /// pool. `rename_all = "lowercase"` would yield `"chromeproxy"`, so the
    /// variant is renamed explicitly to match the internal renderer name
    /// (`"chrome_proxy"`) and `RendererKind::ChromeProxy`.
    #[serde(rename = "chrome_proxy")]
    ChromeProxy,
    Playwright,
}

impl RequestedRenderer {
    /// Returns `Some(name)` for renderers that should be hard-pinned in dispatch.
    /// `Auto` returns `None` — equivalent to omitting the field.
    pub fn pinned_name(self) -> Option<&'static str> {
        match self {
            RequestedRenderer::Auto => None,
            RequestedRenderer::Lightpanda => Some("lightpanda"),
            RequestedRenderer::Chrome => Some("chrome"),
            RequestedRenderer::ChromeProxy => Some("chrome_proxy"),
            RequestedRenderer::Playwright => Some("playwright"),
        }
    }
}

/// Firecrawl-compatible extraction options (used via `extract: { schema: {...} }`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractOptions {
    #[serde(default)]
    pub schema: Option<serde_json::Value>,
}

/// POST /v1/scrape request body.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrapeRequest {
    pub url: String,
    #[serde(default = "default_formats")]
    pub formats: Vec<OutputFormat>,
    #[serde(default = "default_true", alias = "only_main_content")]
    pub only_main_content: bool,
    /// null = auto-detect, true = force JS, false = skip JS
    #[serde(alias = "render_js")]
    pub render_js: Option<bool>,
    /// Milliseconds to wait after JS rendering.
    #[serde(alias = "wait_for")]
    pub wait_for: Option<u64>,
    #[serde(default, alias = "include_tags")]
    pub include_tags: Vec<String>,
    #[serde(default, alias = "exclude_tags")]
    pub exclude_tags: Vec<String>,
    #[serde(alias = "json_schema")]
    pub json_schema: Option<serde_json::Value>,
    #[serde(default)]
    pub headers: HashMap<String, String>,
    /// CSS selector to narrow content before extraction.
    #[serde(default, alias = "css_selector")]
    pub css_selector: Option<String>,
    /// XPath expression to narrow content before extraction.
    #[serde(default)]
    pub xpath: Option<String>,
    /// Strategy for chunking the extracted markdown.
    #[serde(default, alias = "chunk_strategy")]
    pub chunk_strategy: Option<ChunkStrategy>,
    /// Query string for BM25/cosine chunk filtering.
    #[serde(default)]
    pub query: Option<String>,
    /// Filtering algorithm to rank chunks against query.
    #[serde(default, alias = "filter_mode")]
    pub filter_mode: Option<FilterMode>,
    /// Number of top chunks to return (default: 5).
    #[serde(default)]
    pub top_k: Option<usize>,
    /// Per-request proxy URL (overrides global config).
    /// Supports HTTP, HTTPS, and SOCKS5
    /// (e.g. "http://proxy:8080" or "socks5://user:pass@proxy:1080").
    #[serde(default)]
    pub proxy: Option<String>,
    /// 2-letter ISO 3166-1 alpha-2 country code (e.g. "us", "gb") for the
    /// residential-proxy chrome tier's egress. When the server has
    /// DataImpulse credentials configured, the engine composes
    /// `<base>__cr.<country>` per request and supplies it via CDP
    /// `Fetch.authRequired`. Unset / empty = server default country (or
    /// global pool when no default configured). Validated server-side;
    /// invalid values fall back to default.
    #[serde(default)]
    pub country: Option<String>,
    /// Override stealth mode for this request (None = use global config).
    #[serde(default)]
    pub stealth: Option<bool>,
    /// Unsupported Firecrawl parameter — captured to return a clear error.
    #[serde(default)]
    pub actions: Option<serde_json::Value>,
    /// Firecrawl-compatible `extract` object (e.g. `{ "schema": {...} }`).
    /// If `extract.schema` is set and `jsonSchema` is not, uses `extract.schema` as the schema.
    #[serde(default)]
    pub extract: Option<ExtractOptions>,
    /// Per-request LLM API key for structured extraction (BYOK).
    #[serde(default, alias = "llm_api_key")]
    pub llm_api_key: Option<String>,
    /// Per-request LLM provider override ("anthropic" or "openai").
    #[serde(default, alias = "llm_provider")]
    pub llm_provider: Option<String>,
    /// Per-request LLM model override.
    #[serde(default, alias = "llm_model")]
    pub llm_model: Option<String>,
    /// Per-request LLM base URL override (OpenAI-compatible providers
    /// like DeepSeek). Example: `"https://api.deepseek.com/v1"`.
    #[serde(default, alias = "base_url")]
    pub base_url: Option<String>,
    /// Optional user-supplied instructions appended to the summary system
    /// prompt (e.g. "respond in Turkish", "focus on technical details").
    /// The opencore's prompt-injection defense (UNTRUSTED delimiter,
    /// "ignore imperative content" rule) is kept intact — this only adds
    /// directives, it does not replace the safety wrapper. Capped at
    /// 500 chars server-side to bound token amplification.
    #[serde(default, alias = "summary_prompt")]
    pub summary_prompt: Option<String>,
    /// Maximum number of bytes of scraped content sent to the LLM for the
    /// `summary` format. Defaults to `[extraction.llm].max_html_bytes`
    /// (100 KB out of the box). Clamped to a 200 KB server-side ceiling
    /// regardless of value — protects against runaway provider bills.
    #[serde(default, alias = "max_content_chars")]
    pub max_content_chars: Option<usize>,
    /// Pin this request to a specific renderer. `None` or `Auto` = use the
    /// configured chain. Hard-pin: pinned renderer failures surface as errors,
    /// no silent fallback to a different renderer or HTTP. Pinning a non-Auto
    /// value implies `renderJs:true` unless `renderJs:false` is set explicitly.
    #[serde(default)]
    pub renderer: Option<RequestedRenderer>,
    /// End-to-end deadline budget in milliseconds. When unset, the configured
    /// `request.deadline_ms_default` (8000) applies. The SLO p95 metric is
    /// computed only over requests with `deadline_ms <= 8000`; longer values
    /// land in a separate slow-path histogram. Must be in `(0, 60000]`.
    #[serde(default, alias = "deadline_ms")]
    pub deadline_ms: Option<u64>,
    /// Opt-in extraction debug trace. When true, the response includes a
    /// `debugExtraction` field describing every candidate the extractor
    /// considered and why one was selected.
    #[serde(default)]
    pub debug: Option<bool>,
}

fn default_formats() -> Vec<OutputFormat> {
    vec![OutputFormat::Markdown]
}

fn default_true() -> bool {
    true
}

/// Metadata about a scraped page.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageMetadata {
    pub title: Option<String>,
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub og_title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub og_description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub og_image: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub canonical_url: Option<String>,
    #[serde(rename = "sourceURL")]
    pub source_url: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    pub status_code: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rendered_with: Option<String>,
    pub elapsed_ms: u64,
}

/// Token-usage and best-effort cost for one LLM call.
///
/// `estimated_cost_usd` is informational only — provider pricing drifts
/// and this value MUST NOT be used for customer billing.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmUsage {
    pub input_tokens: u32,
    pub output_tokens: u32,
    pub total_tokens: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub estimated_cost_usd: Option<f64>,
    pub model: String,
    pub provider: String,
}

/// A single chunk with optional relevance score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkResult {
    pub content: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,
    pub index: usize,
}

/// Data returned for a single scraped page.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrapeData {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub markdown: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub raw_html: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub plain_text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub json: Option<serde_json::Value>,
    /// LLM-generated summary; populated when `formats` includes `summary`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    /// Token usage + best-effort cost for any LLM call this request triggered
    /// (summary, structured JSON, etc).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub llm_usage: Option<LlmUsage>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub chunks: Option<Vec<ChunkResult>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub warning: Option<String>,
    /// Soft-failure / informational warnings collected through the render
    /// chain. Empty vec serializes as missing for backward compatibility.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub warnings: Vec<String>,
    /// Routing decision metadata (renderer chosen, failover chain).
    /// Surfaced for debug + UI; `None` for legacy paths.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub render_decision: Option<RenderDecision>,
    /// Credit cost attributed to this page (0 = not yet priced).
    #[serde(default, skip_serializing_if = "is_zero_u32")]
    pub credit_cost: u32,
    pub metadata: PageMetadata,
    /// Extraction debug trace; populated only when the request opts in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub debug_extraction: Option<DebugExtraction>,
}

/// Per-request extraction debug trace. One entry per extract() call
/// (multi-attempt JS escalation produces multiple attempts).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DebugExtraction {
    pub attempts: Vec<DebugAttempt>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugAttempt {
    pub renderer: String,
    pub extracted_via: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub candidate_features: Option<serde_json::Value>,
    pub candidates: Vec<DebugCandidate>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugCandidate {
    pub kind: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text_excerpt: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cap_chars: Option<usize>,
    pub score: f64,
}

fn is_zero_u32(v: &u32) -> bool {
    *v == 0
}

/// Generic API response wrapper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T: Serialize> {
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error_code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub warning: Option<String>,
}

impl<T: Serialize> ApiResponse<T> {
    pub fn ok(data: T) -> Self {
        Self {
            success: true,
            data: Some(data),
            error: None,
            error_code: None,
            warning: None,
        }
    }

    pub fn err(msg: impl Into<String>) -> Self {
        Self {
            success: false,
            data: None,
            error: Some(msg.into()),
            error_code: None,
            warning: None,
        }
    }

    pub fn err_with_code(msg: impl Into<String>, code: impl Into<String>) -> Self {
        Self {
            success: false,
            data: None,
            error: Some(msg.into()),
            error_code: Some(code.into()),
            warning: None,
        }
    }
}

// ── Crawl types ──

/// POST /v1/crawl request body.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrawlRequest {
    pub url: String,
    pub max_depth: Option<u32>,
    #[serde(alias = "limit", alias = "max_pages")]
    pub max_pages: Option<u32>,
    #[serde(default = "default_formats")]
    pub formats: Vec<OutputFormat>,
    #[serde(default = "default_true")]
    pub only_main_content: bool,
    #[serde(default, alias = "json_schema")]
    pub json_schema: Option<serde_json::Value>,
    /// null = auto-detect (use global default), true = force JS, false = skip JS.
    /// Applies to every page fetched during the crawl.
    #[serde(default, alias = "render_js")]
    pub render_js: Option<bool>,
    /// Milliseconds to wait after JS rendering on each page.
    #[serde(default, alias = "wait_for")]
    pub wait_for: Option<u64>,
    /// Pin every page in this crawl to a specific renderer. See `ScrapeRequest::renderer`.
    #[serde(default)]
    pub renderer: Option<RequestedRenderer>,
    /// 2-letter ISO 3166-1 alpha-2 country code (e.g. "us", "gb") applied to
    /// every page fetched in this crawl. See `ScrapeRequest::country`.
    #[serde(default)]
    pub country: Option<String>,
}

/// Resolve the effective `render_js` decision from a per-request value and the
/// global default. Per-request always wins when set; otherwise fall back to the
/// default. `None` at both ends means "auto-detect".
///
/// Precedence table:
///
/// | request       | default       | effective    |
/// |---------------|---------------|--------------|
/// | `Some(true)`  | any           | `Some(true)` |
/// | `Some(false)` | any           | `Some(false)`|
/// | `None`        | `Some(true)`  | `Some(true)` |
/// | `None`        | `Some(false)` | `Some(false)`|
/// | `None`        | `None`        | `None`       |
pub fn resolve_render_js(request: Option<bool>, default: Option<bool>) -> Option<bool> {
    request.or(default)
}

/// Resolve the effective pinned renderer name from a per-request value.
///
/// Returns the renderer name (e.g. `"chrome"`) when a non-`Auto` renderer is pinned.
/// `None` and `Some(Auto)` both return `None` — meaning "use the configured chain".
pub fn resolve_pinned_renderer(req: Option<RequestedRenderer>) -> Option<&'static str> {
    req.and_then(|r| r.pinned_name())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_render_js_request_wins_true() {
        assert_eq!(resolve_render_js(Some(true), Some(false)), Some(true));
    }

    #[test]
    fn resolve_render_js_request_wins_false() {
        assert_eq!(resolve_render_js(Some(false), Some(true)), Some(false));
    }

    #[test]
    fn resolve_render_js_falls_back_to_default() {
        assert_eq!(resolve_render_js(None, Some(true)), Some(true));
        assert_eq!(resolve_render_js(None, Some(false)), Some(false));
    }

    #[test]
    fn resolve_render_js_both_none() {
        assert_eq!(resolve_render_js(None, None), None);
    }

    #[test]
    fn crawl_request_accepts_render_js_camel_case() {
        let json = serde_json::json!({
            "url": "https://example.com",
            "renderJs": true,
            "waitFor": 2000
        });
        let req: CrawlRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.render_js, Some(true));
        assert_eq!(req.wait_for, Some(2000));
    }

    #[test]
    fn crawl_request_accepts_render_js_snake_case() {
        let json = serde_json::json!({
            "url": "https://example.com",
            "render_js": false,
            "wait_for": 1500
        });
        let req: CrawlRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.render_js, Some(false));
        assert_eq!(req.wait_for, Some(1500));
    }

    #[test]
    fn crawl_request_render_fields_optional() {
        let json = serde_json::json!({ "url": "https://example.com" });
        let req: CrawlRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.render_js, None);
        assert_eq!(req.wait_for, None);
    }

    #[test]
    fn requested_renderer_deserializes_lowercase() {
        for (s, expected) in [
            ("\"auto\"", RequestedRenderer::Auto),
            ("\"lightpanda\"", RequestedRenderer::Lightpanda),
            ("\"chrome\"", RequestedRenderer::Chrome),
            ("\"playwright\"", RequestedRenderer::Playwright),
        ] {
            let parsed: RequestedRenderer = serde_json::from_str(s).unwrap();
            assert_eq!(parsed, expected, "input {s} should parse to {expected:?}");
        }
    }

    #[test]
    fn requested_renderer_chrome_proxy_round_trip() {
        let parsed: RequestedRenderer = serde_json::from_str("\"chrome_proxy\"").unwrap();
        assert_eq!(parsed, RequestedRenderer::ChromeProxy);
        let json = serde_json::to_string(&RequestedRenderer::ChromeProxy).unwrap();
        assert_eq!(json, "\"chrome_proxy\"");
        assert_eq!(
            resolve_pinned_renderer(Some(RequestedRenderer::ChromeProxy)),
            Some("chrome_proxy")
        );
    }

    #[test]
    fn requested_renderer_rejects_unknown() {
        let result: Result<RequestedRenderer, _> = serde_json::from_str("\"firefox\"");
        assert!(
            result.is_err(),
            "unknown renderer should fail to deserialize"
        );
    }

    #[test]
    fn scrape_request_accepts_renderer_field() {
        let json = serde_json::json!({
            "url": "https://example.com",
            "renderer": "chrome"
        });
        let req: ScrapeRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.renderer, Some(RequestedRenderer::Chrome));
    }

    #[test]
    fn scrape_request_renderer_explicit_null() {
        let json = serde_json::json!({
            "url": "https://example.com",
            "renderer": null
        });
        let req: ScrapeRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.renderer, None);
    }

    #[test]
    fn scrape_request_renderer_omitted() {
        let json = serde_json::json!({ "url": "https://example.com" });
        let req: ScrapeRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.renderer, None);
    }

    #[test]
    fn crawl_request_accepts_renderer_field() {
        let json = serde_json::json!({
            "url": "https://example.com",
            "renderer": "lightpanda"
        });
        let req: CrawlRequest = serde_json::from_value(json).unwrap();
        assert_eq!(req.renderer, Some(RequestedRenderer::Lightpanda));
    }

    #[test]
    fn resolve_pinned_renderer_auto_returns_none() {
        assert_eq!(resolve_pinned_renderer(Some(RequestedRenderer::Auto)), None);
        assert_eq!(resolve_pinned_renderer(None), None);
    }

    #[test]
    fn resolve_pinned_renderer_chrome_returns_name() {
        assert_eq!(
            resolve_pinned_renderer(Some(RequestedRenderer::Chrome)),
            Some("chrome")
        );
        assert_eq!(
            resolve_pinned_renderer(Some(RequestedRenderer::Lightpanda)),
            Some("lightpanda")
        );
        assert_eq!(
            resolve_pinned_renderer(Some(RequestedRenderer::Playwright)),
            Some("playwright")
        );
    }

    #[test]
    fn chrome_proxy_serializes_with_underscore() {
        let json = serde_json::to_string(&RendererKind::ChromeProxy).unwrap();
        assert_eq!(json, "\"chrome_proxy\"");
    }

    #[test]
    fn chrome_proxy_deserializes_from_underscore() {
        let k: RendererKind = serde_json::from_str("\"chrome_proxy\"").unwrap();
        assert_eq!(k, RendererKind::ChromeProxy);
    }

    #[test]
    fn chrome_proxy_as_str() {
        assert_eq!(RendererKind::ChromeProxy.as_str(), "chrome_proxy");
    }
}

/// Status of an async crawl job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CrawlStatus {
    #[serde(rename = "scraping")]
    InProgress,
    #[serde(rename = "completed")]
    Completed,
    #[serde(rename = "failed")]
    Failed,
}

/// GET /v1/crawl/:id response body.
/// Field names match Firecrawl API: status, total, completed, data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlState {
    #[serde(skip_serializing)]
    pub id: Uuid,
    pub success: bool,
    pub status: CrawlStatus,
    pub total: u32,
    pub completed: u32,
    pub data: Vec<ScrapeData>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// POST /v1/crawl start response.
/// Matches Firecrawl format: { success: true, id: "..." }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlStartResponse {
    pub success: bool,
    pub id: String,
}

/// POST /v1/map request body.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MapRequest {
    pub url: String,
    pub max_depth: Option<u32>,
    #[serde(default = "default_true")]
    pub use_sitemap: bool,
    /// When true (default), fall back to a short-budget BFS crawl after the
    /// sitemap phase to fill gaps. Set to false for sitemap-only mode — faster
    /// for sites with rich sitemaps, but may miss pages a sitemap omits.
    #[serde(default = "default_true")]
    pub crawl_fallback: bool,
    /// Custom timeout in seconds (default: 120).
    #[serde(default)]
    pub timeout: Option<u64>,
    /// Tier B — strip tracking params. `Some(_)` overrides TOML.
    #[serde(default)]
    pub strip_tracking_params: Option<bool>,
    /// Tier A — drop action URLs. `Some(_)` overrides TOML.
    #[serde(default)]
    pub drop_action_urls: Option<bool>,
    /// Firecrawl-compatible coarse alias. `Some(true)`: strip every
    /// non-preserved param. `Some(false)`: switch the whole filter off
    /// (raw URLs — the explicit escape hatch).
    #[serde(default)]
    pub ignore_query_parameters: Option<bool>,
    /// Additive on top of `DEFAULT_TRACKING_PARAMS`. Max 64 keys; over-cap → 422.
    #[serde(default)]
    pub extra_tracking_params: Option<Vec<String>>,
    /// Additive on top of `DEFAULT_ACTION_PARAMS`. Max 64 keys; over-cap → 422.
    #[serde(default)]
    pub extra_action_params: Option<Vec<String>>,
    /// Additive on top of `ALWAYS_PRESERVE` + TOML preserves.
    /// Max 64 keys; over-cap → 422.
    #[serde(default)]
    pub preserve_params: Option<Vec<String>>,
}

/// POST /v1/map response data — the discovered links plus filter stats.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MapData {
    pub links: Vec<String>,
    /// Number of URLs the /map filter dropped entirely (Tier A action-URL
    /// matches). `0` when the filter is disabled.
    #[serde(default)]
    pub dropped_action_count: usize,
    /// Number of URLs that had at least one query param stripped by Tier B.
    /// `0` when the filter is disabled.
    #[serde(default)]
    pub stripped_tracking_count: usize,
}

/// POST /v1/map response body.
/// Standard envelope: { success: true, data: { links: [...] } }
pub type MapResponse = ApiResponse<MapData>;

// ── Search types ──

/// Top-level "source" buckets exposed in the `/v1/search` API. Maps to
/// SearXNG's `categories` query parameter (web → general, news → news,
/// images → images).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchSource {
    Web,
    News,
    Images,
}

impl SearchSource {
    /// SearXNG category name for this source.
    pub fn searxng_category(self) -> &'static str {
        match self {
            SearchSource::Web => "general",
            SearchSource::News => "news",
            SearchSource::Images => "images",
        }
    }
}

/// User-facing category modifiers. `Github` and `Research` map to engine
/// filtering; `Pdf` modifies the query with `filetype:pdf` (matches SaaS).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchCategory {
    Github,
    Research,
    Pdf,
}

/// Time-window filter, mirrors Google's `tbs=qdr:*` syntax used by Firecrawl.
/// SearXNG's `time_range` only supports day/week/month/year; `Hour` is mapped
/// to `Day` for parity with the SaaS implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SearchTimeFilter {
    #[serde(rename = "qdr:h")]
    Hour,
    #[serde(rename = "qdr:d")]
    Day,
    #[serde(rename = "qdr:w")]
    Week,
    #[serde(rename = "qdr:m")]
    Month,
    #[serde(rename = "qdr:y")]
    Year,
}

impl SearchTimeFilter {
    /// SearXNG `time_range` string. SearXNG has no hour granularity, so
    /// `Hour` is reported as `day` (lossy; matches SaaS behavior).
    pub fn searxng_time_range(self) -> &'static str {
        match self {
            SearchTimeFilter::Hour | SearchTimeFilter::Day => "day",
            SearchTimeFilter::Week => "week",
            SearchTimeFilter::Month => "month",
            SearchTimeFilter::Year => "year",
        }
    }
}

/// `scrapeOptions` sub-object — a narrow projection of `ScrapeRequest` that
/// we accept on every result from a search. Only the fields the SaaS exposes.
///
/// `formats` defaults to `["markdown"]` so Firecrawl-compatible callers that
/// pass `scrapeOptions: {}` (toggle enrichment without specifying formats)
/// get a sensible default instead of a deserialization error.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchScrapeOptions {
    #[serde(default = "default_formats")]
    pub formats: Vec<OutputFormat>,
    #[serde(default = "default_true")]
    pub only_main_content: bool,
}

/// POST /v1/search request body. Mirrors the zod schema in
/// `crw-saas/src/lib/search-schema.ts`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchRequest {
    pub query: String,
    /// Number of results per source (or total when `sources` is unset).
    /// Defaults to `[search].default_limit` when omitted; clamped to
    /// `[search].max_limit` server-side.
    #[serde(default)]
    pub limit: Option<u32>,
    /// SearXNG `language` parameter (e.g. `"en"`, `"de"`, `"auto"`).
    #[serde(default)]
    pub lang: Option<String>,
    /// Google-style time filter (`qdr:h|d|w|m|y`).
    #[serde(default)]
    pub tbs: Option<SearchTimeFilter>,
    /// When set, results are grouped under `web`/`news`/`images` keys.
    /// When unset, a flat array is returned.
    #[serde(default)]
    pub sources: Option<Vec<SearchSource>>,
    /// User-facing category modifiers. Max 5 entries (matches SaaS).
    #[serde(default)]
    pub categories: Option<Vec<SearchCategory>>,
    /// When set, every `web` result is enriched in-process via the scrape
    /// pipeline (parallel, bounded by `[crawler].max_concurrency`).
    #[serde(default)]
    pub scrape_options: Option<SearchScrapeOptions>,
    /// When true, every scraped result also gets an LLM summary attached to
    /// `SearchResult.summary`. Requires `scrape_options` to be set (so the
    /// markdown exists to summarize). LLM fan-out is bounded by
    /// `[extraction.llm].max_concurrency`.
    #[serde(default, alias = "summarize_results")]
    pub summarize_results: Option<bool>,
    /// When true, a single synthesized answer is generated from the top-N
    /// scraped results. Requires `scrape_options` to be set.
    #[serde(default)]
    pub answer: Option<bool>,
    /// Number of top results to include in answer synthesis (default 5,
    /// capped at 10).
    #[serde(default, alias = "answer_top_n")]
    pub answer_top_n: Option<u32>,
    /// Per-source character cap for the answer prompt (default 8192,
    /// hard-capped at 32768 server-side).
    #[serde(default, alias = "max_chars_per_source")]
    pub max_chars_per_source: Option<usize>,
    /// BYOK fields (mirror `ScrapeRequest`).
    #[serde(default, alias = "llm_api_key")]
    pub llm_api_key: Option<String>,
    #[serde(default, alias = "llm_provider")]
    pub llm_provider: Option<String>,
    #[serde(default, alias = "llm_model")]
    pub llm_model: Option<String>,
    #[serde(default, alias = "base_url")]
    pub base_url: Option<String>,
    /// Optional user-supplied instructions appended to the per-result
    /// summary system prompt. See `ScrapeRequest.summary_prompt`. Capped
    /// at 500 chars server-side.
    #[serde(default, alias = "summary_prompt")]
    pub summary_prompt: Option<String>,
    /// Optional user-supplied instructions appended to the answer-synthesis
    /// system prompt (e.g. "respond in Turkish", "be concise"). The
    /// "answer using ONLY the provided sources" rule and citation discipline
    /// stay intact. Capped at 500 chars server-side.
    #[serde(default, alias = "answer_prompt")]
    pub answer_prompt: Option<String>,
    /// Maximum number of bytes of each per-result markdown sent to the LLM
    /// when `summarize_results` is enabled. Defaults to
    /// `[extraction.llm].max_html_bytes` (100 KB). Clamped to a 200 KB
    /// server-side ceiling. Independent from `max_chars_per_source`, which
    /// caps the answer-synthesis path, not the per-result summary path.
    #[serde(default, alias = "max_content_chars")]
    pub max_content_chars: Option<usize>,
}

/// A single search result (web or news). Mirrors `SearchResult` in
/// `crw-saas/src/lib/search-transform.ts`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
    pub url: String,
    pub title: String,
    pub description: String,
    /// Alias of `description`. Always populated. Emitted so downstream LLM
    /// pipelines that ask for "snippet" (Firecrawl convention) don't need a
    /// rename step. `#[serde(default)]` keeps deserialization permissive for
    /// callers that don't supply it.
    #[serde(default)]
    pub snippet: String,
    pub position: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub score: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub published_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub category: Option<String>,
    // Populated when scrapeOptions is used.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub markdown: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub raw_html: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<PageMetadata>,
    /// LLM-generated summary; populated when `summarizeResults: true`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
}

/// A single image result. Mirrors `ImageResult` in
/// `crw-saas/src/lib/search-transform.ts`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageResult {
    pub url: String,
    pub title: String,
    pub description: String,
    pub image_url: String,
    pub position: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thumbnail_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_format: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resolution: Option<String>,
}

/// Grouped result envelope when `sources` is set on the request.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupedSearchData {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub web: Option<Vec<SearchResult>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub news: Option<Vec<SearchResult>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub images: Option<Vec<ImageResult>>,
}

/// `data` payload of the `/v1/search` response. Either a flat list of
/// results or a grouped object — chosen by whether the request specified
/// `sources`. Untagged: serializes as either an array or an object with
/// `web`/`news`/`images` keys.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SearchData {
    Flat(Vec<SearchResult>),
    Grouped(GroupedSearchData),
}

/// A citation reference in an LLM-synthesized search answer. `position`
/// is clamped to `[0, sources.len())` server-side so fabricated indices
/// can't escape the input source list.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Citation {
    pub url: String,
    pub title: String,
    pub position: u32,
}

/// Wrapper data envelope for `/v1/search` responses. Carries the existing
/// `SearchData` (flat or grouped) alongside optional LLM-generated
/// `answer` + `citations`. Adding sibling fields directly to `SearchData`
/// is impossible because that enum is `#[serde(untagged)]`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResponseData {
    pub results: SearchData,
    /// LLM-synthesized answer over the top-N results; `None` unless
    /// `answer: true` was set on the request.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub answer: Option<String>,
    /// Source citations for the answer. Order is meaningful: citation #0
    /// is `sources[0]`. Capped at 20 entries.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub citations: Vec<Citation>,
    /// Token usage + best-effort cost from the answer synthesis call.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub llm_usage: Option<LlmUsage>,
    /// Soft-failure / partial-result notices (e.g. "answer call rate-limited;
    /// summaries returned without answer").
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub warnings: Vec<String>,
}

/// POST /v1/search response body.
pub type SearchResponse = ApiResponse<SearchResponseData>;

// ── Render result ──

/// Closed enum of renderer kinds used in routing decisions and metrics.
/// Distinct from `RequestedRenderer` (user-facing input) — this is the
/// internal vocabulary for what actually executed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RendererKind {
    Http,
    Lightpanda,
    Chrome,
    #[serde(rename = "chrome_proxy")]
    ChromeProxy,
}

impl RendererKind {
    pub fn as_str(self) -> &'static str {
        match self {
            RendererKind::Http => "http",
            RendererKind::Lightpanda => "lightpanda",
            RendererKind::Chrome => "chrome",
            RendererKind::ChromeProxy => "chrome_proxy",
        }
    }
}

/// Why and how a renderer was chosen for a given request. Surfaced in
/// `FetchResult.render_decision` and exposed to API callers behind a debug gate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum RenderDecision {
    /// User pinned a specific renderer; auto-mode learning is bypassed.
    UserPinned { renderer: RendererKind },
    /// Auto mode used the configured default chain (no host preference yet).
    AutoDefault { chosen: RendererKind },
    /// Auto mode promoted a heavy renderer based on host preference.
    AutoPromoted {
        chosen: RendererKind,
        from: RendererKind,
        reason: String,
    },
    /// Auto mode skipped a renderer because its circuit breaker was open.
    BreakerSkipped {
        skipped: RendererKind,
        chosen: RendererKind,
    },
    /// Failover triggered after the initial choice failed.
    Failover {
        chain: Vec<RendererKind>,
        reason: FailoverErrorKind,
    },
}

/// Closed taxonomy of failure reasons that drive failover and host learning.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FailoverErrorKind {
    /// LightPanda hydration / runtime exception (counts toward promotion).
    NextJsClientError,
    /// LightPanda returned an empty Next.js root shell.
    EmptyNextRoot,
    /// LightPanda timed out.
    LightpandaTimeout,
    /// LightPanda crashed or connection died.
    LightpandaCrash,
    /// Cloudflare challenge detected (combination markers).
    CloudflareChallenge,
    /// Generic placeholder / too-short content.
    PlaceholderContent,
    /// Vendor-specific anti-bot block (Akamai, PerimeterX, DataDome, etc.).
    /// Vendor name is recorded via `crw_vendor_block_total{vendor}` metric
    /// and the renderer warning — not carried in the enum variant to keep
    /// the type `Copy`-friendly.
    VendorBlock,
    /// JS renderer returned a 4xx/5xx HTTP status (e.g. 403, 429) — same
    /// status set the HTTP tier escalates on. Caught in the JS tier so a
    /// "200 with bot HTML" or "403 with content" can't masquerade as success.
    StatusBlocked,
    /// The `crw_extract::antibot` classifier flagged a block the lighter
    /// `detector` heuristics missed (e.g. a "blocked by network security"
    /// WAF page served with HTTP 200). Drives escalation toward the
    /// residential `chrome_proxy` tier; counts toward host promotion.
    AntibotBlock,
    /// Network error during render.
    NetworkError,
    /// Other / unclassified failure (does NOT count for promotion).
    Other,
}

impl FailoverErrorKind {
    /// Strict failure predicate: only LightPanda-specific failures should
    /// drive host preference promotion. CF challenges and network errors
    /// are not LightPanda's fault.
    pub fn counts_for_promotion(&self) -> bool {
        matches!(
            self,
            FailoverErrorKind::NextJsClientError
                | FailoverErrorKind::EmptyNextRoot
                | FailoverErrorKind::LightpandaTimeout
                | FailoverErrorKind::LightpandaCrash
                | FailoverErrorKind::PlaceholderContent
                | FailoverErrorKind::AntibotBlock
        )
    }

    /// Stable camelCase identifier matching the JSON `serde` rendering.
    /// Used in user-facing warnings so the string a client sees in a
    /// `warnings[]` entry matches the `renderDecision.reason` field.
    pub fn as_str(&self) -> &'static str {
        match self {
            FailoverErrorKind::NextJsClientError => "nextJsClientError",
            FailoverErrorKind::EmptyNextRoot => "emptyNextRoot",
            FailoverErrorKind::LightpandaTimeout => "lightpandaTimeout",
            FailoverErrorKind::LightpandaCrash => "lightpandaCrash",
            FailoverErrorKind::CloudflareChallenge => "cloudflareChallenge",
            FailoverErrorKind::PlaceholderContent => "placeholderContent",
            FailoverErrorKind::VendorBlock => "vendorBlock",
            FailoverErrorKind::StatusBlocked => "statusBlocked",
            FailoverErrorKind::AntibotBlock => "antibotBlock",
            FailoverErrorKind::NetworkError => "networkError",
            FailoverErrorKind::Other => "other",
        }
    }
}

/// Result of fetching + optionally rendering a page.
#[derive(Debug, Clone)]
pub struct FetchResult {
    pub url: String,
    /// Final URL after redirects, populated only when it differs from the
    /// requested `url`. None means no redirect or scheme/path was identical.
    pub final_url: Option<String>,
    pub status_code: u16,
    pub html: String,
    pub content_type: Option<String>,
    pub raw_bytes: Option<Vec<u8>>,
    pub rendered_with: Option<String>,
    pub elapsed_ms: u64,
    pub warning: Option<String>,
    /// Routing decision metadata. `None` for legacy / non-instrumented paths.
    pub render_decision: Option<RenderDecision>,
    /// Credit cost for this request (set by routing layer; 0 = not yet priced).
    pub credit_cost: u32,
    /// Soft-failure / informational warnings to surface to the caller.
    pub warnings: Vec<String>,
    /// Set by chrome renderer when the navigation budget elapsed before
    /// `loadEventFired` and we snapshotted the partial DOM. Mid-load HTML may
    /// still extract usefully (`single.rs` decides success on md length).
    pub truncated: bool,
    /// Set when `Deadline::remaining() == 0` was observed at result-build time.
    /// Stricter than `truncated` — caller's whole budget is spent.
    pub deadline_exceeded: bool,
    /// XHR/fetch responses captured during navigation. Empty unless the
    /// renderer ran with network capture enabled. Used by extraction as a
    /// fallback content source when DOM-based extraction is low quality.
    pub captured_responses: Vec<CapturedNetworkResponse>,
}

/// A single XHR/fetch response captured via CDP Network domain.
#[derive(Debug, Clone)]
pub struct CapturedNetworkResponse {
    pub url: String,
    pub request_id: String,
    pub status: u16,
    pub mime_type: Option<String>,
    pub body: Option<String>,
    pub body_size_bytes: usize,
}
