//! Text recognition via `VNRecognizeTextRequest`.
//!
//! Use [`recognize`] for one-shot OCR of an image file. The image
//! must already be decoded (we go through `CGImageSourceCreateWithURL`
//! which handles PNG/JPEG/HEIC/TIFF/etc).
//!
//! For screen capture → OCR pipelines, save the screenshot to a
//! temp file and pass the path. The Vision call itself is sub-100ms
//! on Apple Silicon, slower (but still subsecond) on Intel.

use std::path::Path;

#[cfg(all(target_os = "macos", car_vision_swift_built))]
use std::ffi::{c_char, CStr, CString};
#[cfg(all(target_os = "macos", car_vision_swift_built))]
use std::ptr;

use serde::{Deserialize, Serialize};

use crate::{Observation, Result};
// Only referenced by the Apple branch and the tests; the non-Apple branch
// delegates error construction to the Tesseract backend.
#[cfg(any(all(target_os = "macos", car_vision_swift_built), test))]
use crate::VisionError;

/// Knobs for a single OCR call.
///
/// Defaults are tuned for *agent* OCR — accurate recognition with
/// language correction enabled. Callers chasing latency can flip
/// `fast_path` to use Vision's faster (less accurate) recognition
/// pass.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OcrConfig {
    /// `false` (default): `.accurate`. `true`: `.fast` — drops
    /// recognition latency by roughly 5x at the cost of accuracy on
    /// small or stylized text.
    #[serde(default)]
    pub fast_path: bool,
    /// BCP-47 language tags. Empty = Vision picks. Set explicitly
    /// (e.g. `["en-US"]`) for non-trivial speedups when you know
    /// the language.
    #[serde(default)]
    pub languages: Vec<String>,
    /// Apply language model corrections to candidate readings.
    /// Almost always wanted; the lone exception is when you're
    /// reading codes/IDs that look like words.
    #[serde(default = "default_true")]
    pub language_correction: bool,
    /// Minimum height of recognizable text, normalized to image
    /// height. `0` means no floor. Setting this floors out tiny
    /// noise text and speeds the request up significantly.
    #[serde(default)]
    pub minimum_text_height: f32,
}

fn default_true() -> bool {
    true
}

/// Run text recognition over `image_path`.
///
/// Returns observations in reading order as Vision determined it
/// (top-to-bottom, left-to-right after layout analysis). Empty
/// `Vec` when the image has no recognized text.
pub fn recognize(image_path: &Path, config: &OcrConfig) -> Result<Vec<Observation>> {
    #[cfg(not(all(target_os = "macos", car_vision_swift_built)))]
    {
        // No Apple Vision shim in this build — fall back to the
        // cross-platform Tesseract CLI backend (car#360). It reports its
        // own availability via `is_available()` and maps Tesseract's TSV
        // output to the same `Observation` shape.
        crate::ocr_tesseract::recognize(image_path, config)
    }

    #[cfg(all(target_os = "macos", car_vision_swift_built))]
    {
        let path_str = image_path
            .to_str()
            .ok_or_else(|| VisionError::InvalidInput("image path is not valid UTF-8".into()))?;
        if path_str.is_empty() {
            return Err(VisionError::InvalidInput("image path is empty".into()));
        }

        let path_c = CString::new(path_str)
            .map_err(|e| VisionError::InvalidInput(format!("path has interior NUL: {e}")))?;
        let cfg_json = serde_json::to_string(config)
            .map_err(|e| VisionError::InvalidInput(format!("config serialization: {e}")))?;
        let cfg_c = CString::new(cfg_json)
            .map_err(|e| VisionError::InvalidInput(format!("config has interior NUL: {e}")))?;

        let mut out_json: *mut c_char = ptr::null_mut();
        let mut out_err: *mut c_char = ptr::null_mut();

        let rc = unsafe {
            crate::ffi::car_vision_ocr(
                path_c.as_ptr(),
                cfg_c.as_ptr(),
                &mut out_json as *mut *mut c_char,
                &mut out_err as *mut *mut c_char,
            )
        };

        if rc != 0 {
            return Err(VisionError::RequestFailed(consume_swift_string(out_err)));
        }
        let body = consume_swift_string(out_json);
        serde_json::from_str::<Vec<Observation>>(&body)
            .map_err(|e| VisionError::MalformedResponse(format!("{e}: body={body}")))
    }
}

#[cfg(all(target_os = "macos", car_vision_swift_built))]
fn consume_swift_string(ptr: *mut c_char) -> String {
    if ptr.is_null() {
        return String::new();
    }
    let s = unsafe { CStr::from_ptr(ptr) }
        .to_string_lossy()
        .into_owned();
    unsafe { crate::ffi::car_vision_free_string(ptr) };
    s
}

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

    // Off macOS, recognize() now dispatches to the cross-platform Tesseract
    // backend (car#360) instead of returning PlatformUnsupported. A
    // nonexistent file fails the same way whether or not tesseract is
    // installed: RequestFailed (missing file, or "tesseract not found").
    #[cfg(not(target_os = "macos"))]
    #[test]
    fn missing_file_is_request_failed_off_macos() {
        let result = recognize(
            Path::new("/tmp/car-vision-no-such-file-here.png"),
            &OcrConfig::default(),
        );
        assert!(matches!(result, Err(VisionError::RequestFailed(_))));
    }

    #[cfg(not(target_os = "macos"))]
    #[test]
    fn empty_path_is_invalid_input_off_macos() {
        let result = recognize(Path::new(""), &OcrConfig::default());
        assert!(matches!(result, Err(VisionError::InvalidInput(_))));
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn rejects_empty_path() {
        let result = recognize(Path::new(""), &OcrConfig::default());
        assert!(matches!(result, Err(VisionError::InvalidInput(_))));
    }

    #[cfg(target_os = "macos")]
    #[test]
    fn rejects_missing_file() {
        let result = recognize(
            Path::new("/tmp/car-vision-no-such-file-here.png"),
            &OcrConfig::default(),
        );
        match result {
            Err(VisionError::RequestFailed(_)) => {}
            other => panic!("expected RequestFailed, got {other:?}"),
        }
    }
}
