//! Cross-platform OCR fallback via the Tesseract CLI (car#360).
//!
//! When the Apple Vision shim isn't compiled in — every non-macOS host,
//! and macOS builds without the Swift toolchain — OCR falls back to the
//! `tesseract` command-line tool if it's installed on `PATH`. This keeps
//! [`crate::ocr::recognize`] working, and `is_available()` honest, off
//! macOS **without a new build or library dependency**: we shell out and
//! parse Tesseract's TSV output, mapping each recognized word to the same
//! [`Observation`] shape (normalized coordinates, bottom-left origin) the
//! Apple backend produces, so existing callers are unaffected.
//!
//! Why the CLI and not a linked `libtesseract`: a build-time C dependency
//! would have to be present in every Linux/Windows CI and release
//! environment (project rule #1 forbids feature-gating it away), and a
//! pure-Rust engine would pull a heavy model-download pipeline. Shelling
//! out adds zero crate dependencies, builds everywhere, and degrades
//! gracefully — `is_available()` is simply `false` when `tesseract` isn't
//! installed, exactly as the Apple path reports `false` off macOS.

use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::OnceLock;

use crate::{ocr::OcrConfig, Observation, Result, VisionError};

/// Cached probe: is the `tesseract` binary invokable on `PATH`?
static AVAILABLE: OnceLock<bool> = OnceLock::new();

/// True when the `tesseract` CLI is installed and runnable.
pub fn is_available() -> bool {
    *AVAILABLE.get_or_init(|| {
        Command::new("tesseract")
            .arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    })
}

/// Run OCR over `image_path` via the Tesseract CLI.
pub fn recognize(image_path: &Path, config: &OcrConfig) -> Result<Vec<Observation>> {
    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()));
    }
    if !image_path.exists() {
        return Err(VisionError::RequestFailed(format!(
            "image not found: {path_str}"
        )));
    }
    if !is_available() {
        return Err(VisionError::RequestFailed(
            "tesseract CLI not found on PATH; install tesseract for cross-platform OCR".into(),
        ));
    }

    // `tesseract <img> stdout [-l langs] [-c ...] tsv` — TSV gives one row
    // per layout element with pixel bbox + per-word confidence.
    //
    // Tesseract's CLI parser has no `--` end-of-options marker, so an
    // `image_path` that starts with `-` (caller-supplied over the
    // `vision.ocr` RPC) would be read as a flag, not the input file. Only a
    // *relative* path can lead with `-` (absolute paths lead with `/`); make
    // it unambiguous with a `./` prefix so it can never be parsed as an option.
    let arg_path: std::borrow::Cow<'_, Path> = if path_str.starts_with('-') {
        std::borrow::Cow::Owned(Path::new(".").join(image_path))
    } else {
        std::borrow::Cow::Borrowed(image_path)
    };
    let mut cmd = Command::new("tesseract");
    cmd.arg(arg_path.as_ref()).arg("stdout");
    if !config.languages.is_empty() {
        let langs = config
            .languages
            .iter()
            .map(|l| bcp47_to_tesseract(l))
            .collect::<Vec<_>>()
            .join("+");
        cmd.arg("-l").arg(langs);
    }
    // `language_correction = false` is for codes/IDs that look like words:
    // turn off the dictionary DAWGs so Tesseract won't "correct" them.
    if !config.language_correction {
        cmd.args(["-c", "load_system_dawg=0", "-c", "load_freq_dawg=0"]);
    }
    cmd.arg("tsv");

    let output = cmd
        .output()
        .map_err(|e| VisionError::RequestFailed(format!("failed to run tesseract: {e}")))?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        return Err(VisionError::RequestFailed(format!(
            "tesseract exited with {}: {}",
            output.status,
            err.trim()
        )));
    }
    let tsv = String::from_utf8_lossy(&output.stdout);
    parse_tsv(&tsv, config.minimum_text_height)
}

/// Parse Tesseract's TSV output into [`Observation`]s.
///
/// The TSV columns are: `level page block par line word left top width
/// height conf text`. The `level == 1` row carries the full page pixel
/// dimensions (used to normalize), and `level == 5` rows are words.
/// Tesseract's origin is top-left; we flip Y to the bottom-left origin
/// the [`Observation`] contract specifies.
fn parse_tsv(tsv: &str, minimum_text_height: f32) -> Result<Vec<Observation>> {
    let mut lines = tsv.lines();
    let header = lines
        .next()
        .ok_or_else(|| VisionError::MalformedResponse("empty tesseract output".into()))?;
    if !header.starts_with("level") {
        return Err(VisionError::MalformedResponse(format!(
            "unexpected tesseract TSV header: {header}"
        )));
    }

    let mut page_w = 0f64;
    let mut page_h = 0f64;
    let mut out = Vec::new();
    for line in lines {
        if line.trim().is_empty() {
            continue;
        }
        let cols: Vec<&str> = line.split('\t').collect();
        if cols.len() < 12 {
            continue;
        }
        let level: i32 = cols[0].trim().parse().unwrap_or(-1);
        let left: f64 = cols[6].trim().parse().unwrap_or(0.0);
        let top: f64 = cols[7].trim().parse().unwrap_or(0.0);
        let width: f64 = cols[8].trim().parse().unwrap_or(0.0);
        let height: f64 = cols[9].trim().parse().unwrap_or(0.0);

        if level == 1 {
            // Page row: full image dimensions in pixels. Tesseract emits a
            // page row before that page's word rows, so words always
            // normalize against their own page's dims (multi-page TIFF/PDF
            // just resets these per page — still correct, since each page's
            // words follow its page row).
            page_w = width;
            page_h = height;
            continue;
        }
        if level != 5 {
            continue; // word rows only
        }

        let conf: f32 = cols[10].trim().parse().unwrap_or(-1.0);
        let text = cols.get(11).copied().unwrap_or("").trim();
        if text.is_empty() || conf < 0.0 {
            continue; // skip layout placeholders / rejected words
        }
        if page_w <= 0.0 || page_h <= 0.0 {
            return Err(VisionError::MalformedResponse(
                "tesseract TSV missing page dimensions".into(),
            ));
        }

        let h_norm = height / page_h;
        if minimum_text_height > 0.0 && (h_norm as f32) < minimum_text_height {
            continue;
        }
        out.push(Observation {
            text: text.to_string(),
            confidence: (conf / 100.0).clamp(0.0, 1.0),
            x: left / page_w,
            // Top-left pixel origin → bottom-left normalized origin.
            y: 1.0 - (top + height) / page_h,
            w: width / page_w,
            h: h_norm,
        });
    }
    Ok(out)
}

/// Map a BCP-47 language tag to a Tesseract traineddata name. Tesseract
/// uses ISO 639-2/T codes (e.g. `eng`, `fra`). We map the common primary
/// subtags and pass anything else through unchanged, so a caller can also
/// supply a Tesseract code (`"eng"`) directly.
fn bcp47_to_tesseract(tag: &str) -> String {
    let primary = tag.split(['-', '_']).next().unwrap_or(tag).to_lowercase();
    match primary.as_str() {
        "en" => "eng",
        "fr" => "fra",
        "de" => "deu",
        "es" => "spa",
        "it" => "ita",
        "pt" => "por",
        "nl" => "nld",
        "ru" => "rus",
        "zh" => "chi_sim",
        "ja" => "jpn",
        "ko" => "kor",
        "ar" => "ara",
        _ => return tag.to_string(),
    }
    .to_string()
}

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

    const SAMPLE_TSV: &str = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n\
1\t1\t0\t0\t0\t0\t0\t0\t200\t100\t-1\t\n\
2\t1\t1\t0\t0\t0\t10\t10\t180\t30\t-1\t\n\
5\t1\t1\t1\t1\t1\t10\t10\t80\t20\t95\tHello\n\
5\t1\t1\t1\t1\t2\t100\t10\t90\t20\t88\tWorld\n\
5\t1\t1\t1\t2\t1\t10\t80\t40\t5\t70\ttiny";

    #[test]
    fn parses_words_with_normalized_bottom_left_coords() {
        let obs = parse_tsv(SAMPLE_TSV, 0.0).unwrap();
        assert_eq!(obs.len(), 3, "two words + the small one");
        // "Hello": left=10,top=10,w=80,h=20 over a 200x100 page.
        let hello = &obs[0];
        assert_eq!(hello.text, "Hello");
        assert!((hello.x - 0.05).abs() < 1e-9); // 10/200
        assert!((hello.w - 0.40).abs() < 1e-9); // 80/200
        assert!((hello.h - 0.20).abs() < 1e-9); // 20/100
                                                // bottom-left y = 1 - (top+height)/page_h = 1 - 30/100 = 0.70
        assert!((hello.y - 0.70).abs() < 1e-9);
        assert!((hello.confidence - 0.95).abs() < 1e-6);
    }

    #[test]
    fn minimum_text_height_filters_tiny_text() {
        // The "tiny" word has height 5/100 = 0.05; floor it out at 0.10.
        let obs = parse_tsv(SAMPLE_TSV, 0.10).unwrap();
        assert!(obs.iter().all(|o| o.text != "tiny"));
        assert_eq!(obs.len(), 2);
    }

    #[test]
    fn skips_rejected_and_empty_words() {
        // conf=-1 / empty-text rows (page, block) must not become observations.
        let obs = parse_tsv(SAMPLE_TSV, 0.0).unwrap();
        assert!(obs.iter().all(|o| !o.text.is_empty()));
        assert!(obs.iter().all(|o| o.confidence >= 0.0));
    }

    #[test]
    fn rejects_non_tsv_output() {
        let err = parse_tsv("not a tsv at all", 0.0);
        assert!(matches!(err, Err(VisionError::MalformedResponse(_))));
    }

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

    #[test]
    fn missing_file_is_request_failed() {
        let r = recognize(
            Path::new("/tmp/car-vision-tesseract-no-such-file.png"),
            &OcrConfig::default(),
        );
        assert!(matches!(r, Err(VisionError::RequestFailed(_))));
    }

    #[test]
    fn flag_like_path_is_rejected_before_reaching_tesseract() {
        // A relative path starting with '-' must not be treated as a flag.
        // It doesn't exist, so we get RequestFailed (not-found) — the point
        // is we never reach argv where tesseract would parse "-l.png" as -l.
        let r = recognize(Path::new("-l.png"), &OcrConfig::default());
        assert!(matches!(r, Err(VisionError::RequestFailed(_))));
    }

    #[test]
    fn bcp47_maps_common_tags_and_passes_through_codes() {
        assert_eq!(bcp47_to_tesseract("en-US"), "eng");
        assert_eq!(bcp47_to_tesseract("fr"), "fra");
        assert_eq!(bcp47_to_tesseract("eng"), "eng"); // already a tesseract code
        assert_eq!(bcp47_to_tesseract("xyz"), "xyz"); // unknown → pass through
    }
}
