//! Wire-contract DTOs for the Syn OCR service.
//!
//! These types mirror the server's request/response for
//! `POST /api/v1/syn/ocr/extract-json` so that every consumer shares ONE
//! definition of the wire shape instead of hand-maintaining its own copy.
//! Hand-rolled copies drift silently — a non-`Option` field that panics on
//! an error response, or a renamed field that 422s — and a single
//! source-of-truth crate prevents that class of bug.
//!
//! **Canonical types follow the server**: nullable fields are `Option`,
//! widths are `f64`/`i64`/`u64`, and every field the server omits when empty
//! (`regions`, `corrected_text`) is `#[serde(default)]` here so an older or
//! text-only-engine response still deserialises.
//!
//! v0.1 scope: request + response + regions. The `drug_corrections` audit
//! vector is intentionally deferred to a later version — serde silently
//! ignores the field on the wire until a consumer needs it.

use serde::{Deserialize, Serialize};

/// Request body for `POST /api/v1/syn/ocr/extract-json`.
///
/// `image_base64` is the ONLY required field (standard-alphabet base64 of
/// the page image). The optional fields steer the router; leave them `None`
/// to let the server pick. Send the tenant via the `X-Tenant-Id` header
/// (NOT a body field) — without it the server applies its default policy.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SynExtractRequest {
    pub image_base64: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,
    /// Pin a specific engine by id. Normally `None` — set only to force one
    /// when the default route is unavailable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub engine: Option<String>,
    /// `"handwriting"` | `"printed_thai"` | `"mixed"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub doc_type: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub high_stakes: Option<bool>,
    /// `"th"` | `"en"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hint_lang: Option<String>,
}

/// Response body from `POST /api/v1/syn/ocr/extract-json`.
///
/// `extracted_text`/`confidence`/`bbox_count` are `Option` because the
/// server returns them `null` on a failed extraction (and some engines
/// never produce a confidence or bbox count). `regions` and `corrected_text`
/// are omitted from the JSON when empty — `#[serde(default)]` keeps
/// deserialisation non-breaking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SynExtractResponse {
    pub audit_id: String,
    /// Id of the engine that produced this result. Opaque to clients.
    pub engine_used: String,
    /// Why the router picked `engine_used`. Open-ended string whose values
    /// grow over time — treat as opaque and do NOT match exhaustively.
    pub router_reason: String,
    /// `"succeeded"` | `"engine_failed"` | `"pii_strict_block"` | `"budget_exceeded"`.
    pub status: String,
    #[serde(default)]
    pub extracted_text: Option<String>,
    #[serde(default)]
    pub confidence: Option<f64>,
    #[serde(default)]
    pub bbox_count: Option<i64>,
    pub cost_usd: f64,
    pub latency_ms: u64,
    /// Per-region OCR detail. Empty for engines that don't expose geometry
    /// (text-only engines leave it absent).
    #[serde(default)]
    pub regions: Vec<OcrRegion>,
    /// Drug-name post-correction: a copy of `extracted_text` with garbled
    /// drug names fuzzy-corrected. `None` when no correction fired. (The
    /// per-token correction audit is deferred to a later version.)
    #[serde(default)]
    pub corrected_text: Option<String>,
}

impl SynExtractResponse {
    /// `true` iff the server reports a successful extraction.
    pub fn succeeded(&self) -> bool {
        self.status == "succeeded"
    }

    /// The corrected text when present, else the raw extracted text, else `""`.
    /// The common "give me the best available text" accessor.
    pub fn best_text(&self) -> &str {
        self.corrected_text
            .as_deref()
            .or(self.extracted_text.as_deref())
            .unwrap_or("")
    }
}

/// One OCR region: geometry + recognised text + per-region confidence.
/// `region_id` follows `format!("r-{page}-{idx}")` for provenance.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OcrRegion {
    pub region_id: String,
    pub page: u32,
    /// `[x0, y0, x1, y1]` in source-image pixel coordinates, top-left origin.
    pub bbox: [f32; 4],
    pub text: String,
    #[serde(default)]
    pub confidence: Option<f32>,
    /// Layout class when the engine carries one (`"handwritten"`, `"table"`,
    /// `"stamp"`, …); `None` for engines that don't tag.
    #[serde(default)]
    pub semantic_tag: Option<String>,
}

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

    #[test]
    fn request_serialises_image_base64_and_omits_none() {
        let req = SynExtractRequest {
            image_base64: "QUJD".to_string(),
            filename: Some("order.png".to_string()),
            engine: Some("engine-a".to_string()),
            ..Default::default()
        };
        let json = serde_json::to_string(&req).unwrap();
        assert!(json.contains("\"image_base64\":\"QUJD\""));
        assert!(json.contains("engine-a"));
        // None fields must be omitted entirely (not sent as null / empty key).
        assert!(!json.contains("doc_type"));
        assert!(!json.contains("hint_lang"));
    }

    #[test]
    fn parses_succeeded_response_with_regions() {
        let json = r#"{
            "audit_id": "a-1", "engine_used": "engine-a",
            "router_reason": "route-default", "status": "succeeded",
            "extracted_text": "Amoxicillin 500mg", "confidence": 0.93,
            "bbox_count": 2, "cost_usd": 0.0, "latency_ms": 380,
            "regions": [{"region_id":"r-1-0","page":1,"bbox":[0.0,0.0,10.0,5.0],"text":"Amoxicillin"}]
        }"#;
        let r: SynExtractResponse = serde_json::from_str(json).unwrap();
        assert!(r.succeeded());
        assert_eq!(r.best_text(), "Amoxicillin 500mg");
        assert_eq!(r.regions.len(), 1);
        assert_eq!(r.regions[0].region_id, "r-1-0");
        assert_eq!(r.confidence, Some(0.93));
    }

    #[test]
    fn parses_engine_failed_with_null_optionals() {
        // The exact shape a non-Option struct would panic on.
        let json = r#"{
            "audit_id": "a-2", "engine_used": "engine-a",
            "router_reason": "route-default", "status": "engine_failed",
            "extracted_text": null, "confidence": null, "bbox_count": null,
            "cost_usd": 0.0, "latency_ms": 12
        }"#;
        let r: SynExtractResponse = serde_json::from_str(json).unwrap();
        assert!(!r.succeeded());
        assert_eq!(r.extracted_text, None);
        assert_eq!(r.best_text(), "");
        assert!(r.regions.is_empty());
    }

    #[test]
    fn parses_minimal_response_with_omitted_optional_fields() {
        // Text-only engine: regions + corrected_text absent entirely.
        let json = r#"{
            "audit_id": "a-3", "engine_used": "engine-b",
            "router_reason": "route-fallback",
            "status": "succeeded", "extracted_text": "hello",
            "confidence": null, "bbox_count": null,
            "cost_usd": 0.0, "latency_ms": 4
        }"#;
        let r: SynExtractResponse = serde_json::from_str(json).unwrap();
        assert!(r.succeeded());
        assert!(r.regions.is_empty());
        assert_eq!(r.corrected_text, None);
        assert_eq!(r.best_text(), "hello");
    }

    #[test]
    fn corrected_text_wins_over_extracted_text() {
        let json = r#"{
            "audit_id": "a-4", "engine_used": "engine-a",
            "router_reason": "route-default", "status": "succeeded",
            "extracted_text": "Amoxicilin", "corrected_text": "Amoxicillin",
            "cost_usd": 0.0, "latency_ms": 100
        }"#;
        let r: SynExtractResponse = serde_json::from_str(json).unwrap();
        assert_eq!(r.best_text(), "Amoxicillin");
    }
}
