//! Multi-format file reader. Parses common document/data/code formats to text
//! the model can use. Best-effort: uses widely-available CLI tools when needed
//! (pdftotext, unzip, textutil) and degrades gracefully when they're absent.

use std::path::Path;
use std::process::Command;

pub const MAX_CHARS: usize = 60_000;

fn ext(path: &Path) -> String {
    path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}

fn have(cmd: &str) -> bool {
    Command::new("which").arg(cmd).output().map(|o| o.status.success()).unwrap_or(false)
}

fn run(cmd: &str, args: &[&str]) -> Option<String> {
    let out = Command::new(cmd).args(args).output().ok()?;
    if out.status.success() {
        Some(String::from_utf8_lossy(&out.stdout).into_owned())
    } else {
        None
    }
}

fn truncate(s: String) -> String {
    if s.len() <= MAX_CHARS {
        s
    } else {
        let head: String = s.chars().take(MAX_CHARS).collect();
        format!("{head}\n[...truncated, {} total chars...]", s.len())
    }
}

/// Read and parse a file to text. Returns model-facing content.
pub fn read_file(path: &Path) -> String {
    if !path.exists() {
        return format!("Error: file not found: {}", path.display());
    }
    if path.is_dir() {
        return format!("Error: {} is a directory (use run_shell_command `ls` to list it)", path.display());
    }
    let e = ext(path);
    match e.as_str() {
        // ---- plain text / code / data ----
        "" | "txt" | "md" | "markdown" | "rs" | "go" | "py" | "js" | "ts" | "tsx" | "jsx"
        | "java" | "kt" | "rb" | "php" | "c" | "h" | "cpp" | "hpp" | "cs" | "swift" | "scala"
        | "sh" | "bash" | "zsh" | "sql" | "toml" | "yaml" | "yml" | "ini" | "cfg" | "conf"
        | "json" | "jsonl" | "csv" | "tsv" | "xml" | "html" | "htm" | "css" | "scss" | "vue"
        | "svelte" | "proto" | "lock" | "env" | "gitignore" | "dockerfile" | "makefile" => {
            match std::fs::read_to_string(path) {
                Ok(s) => truncate(s),
                Err(_) => format!("Error: {} is not valid UTF-8 text (binary file?)", path.display()),
            }
        }
        // ---- PDF ----
        "pdf" => {
            if have("pdftotext") {
                match run("pdftotext", &["-layout", &path.display().to_string(), "-"]) {
                    Some(s) if !s.trim().is_empty() => truncate(s),
                    _ => "PDF appears to have no extractable text (scanned/image PDF). OCR is needed — try a vision model with /attach.".into(),
                }
            } else {
                "Cannot parse PDF: `pdftotext` not installed. Install poppler (`brew install poppler` / `apt install poppler-utils`), or use the `pdf` skill with run_shell_command.".into()
            }
        }
        // ---- Office Open XML (docx/pptx) ----
        "docx" | "pptx" => {
            let inner = if e == "docx" { "word/document.xml" } else { "ppt/slides/slide1.xml" };
            if have("unzip") {
                match run("unzip", &["-p", &path.display().to_string(), inner]) {
                    Some(xml) if !xml.trim().is_empty() => truncate(strip_xml(&xml)),
                    _ => format!("Could not extract text from {e}. Try the `{e}` skill (anthropics/skills) with run_shell_command for full fidelity."),
                }
            } else {
                format!("Cannot parse {e}: `unzip` not available. Use the `{e}` skill with run_shell_command.")
            }
        }
        // ---- Spreadsheets ----
        "xlsx" | "xls" => {
            "Spreadsheet parsing: use the `xlsx` skill (already installed) with run_shell_command + python/openpyxl for accurate cell data. read_file only handles text formats; export to .csv first for a quick view.".into()
        }
        // ---- Images ----
        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => {
            let meta = image_info(path);
            format!(
                "{meta}\nThis is an image. To let the model SEE it, attach it with /attach {} and use a vision-capable model (e.g. /model gemini, or a local llava/qwen2.5-vl). read_file does not perform OCR.",
                path.display()
            )
        }
        other => match std::fs::read_to_string(path) {
            // Unknown extension: try as text, else report binary.
            Ok(s) if s.is_ascii() || s.chars().take(1000).all(|c| !c.is_control() || c == '\n' || c == '\t' || c == '\r') => truncate(s),
            _ => format!("Unsupported or binary file type '.{other}'. Use run_shell_command with an appropriate tool."),
        },
    }
}

/// Strip XML tags, decode a few entities, collapse whitespace (for docx/pptx).
fn strip_xml(xml: &str) -> String {
    let mut out = String::new();
    let mut in_tag = false;
    let mut last_tag = String::new();
    for ch in xml.chars() {
        match ch {
            '<' => {
                in_tag = true;
                last_tag.clear();
            }
            '>' => {
                in_tag = false;
                // Paragraph / line break tags become newlines.
                if last_tag.starts_with("/w:p") || last_tag.starts_with("w:br") || last_tag.starts_with("a:p") {
                    out.push('\n');
                }
            }
            c if in_tag => last_tag.push(c),
            c => out.push(c),
        }
    }
    let decoded = out
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&apos;", "'");
    decoded.lines().map(|l| l.trim_end()).collect::<Vec<_>>().join("\n")
}

const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Standard base64 (no line breaks).
fn base64(data: &[u8]) -> String {
    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
    for chunk in data.chunks(3) {
        let b = [
            chunk[0],
            *chunk.get(1).unwrap_or(&0),
            *chunk.get(2).unwrap_or(&0),
        ];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
        out.push(B64[((n >> 18) & 63) as usize] as char);
        out.push(B64[((n >> 12) & 63) as usize] as char);
        out.push(if chunk.len() > 1 { B64[((n >> 6) & 63) as usize] as char } else { '=' });
        out.push(if chunk.len() > 2 { B64[(n & 63) as usize] as char } else { '=' });
    }
    out
}

/// Encode an image file as a `data:` URL for multimodal model input.
/// Returns None for non-image files or read errors.
pub fn image_data_url(path: &Path) -> Option<String> {
    let mime = match ext(path).as_str() {
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "bmp" => "image/bmp",
        _ => return None,
    };
    let bytes = std::fs::read(path).ok()?;
    // Guard against absurdly large inlines (~10 MB raw).
    if bytes.len() > 10_000_000 {
        return None;
    }
    Some(format!("data:{mime};base64,{}", base64(&bytes)))
}

/// Best-effort image dimensions from the file header (no image crate).
pub fn image_info(path: &Path) -> String {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) => return format!("image: {} (could not read: {e})", path.display()),
    };
    let size_kb = bytes.len() / 1024;
    let dims = png_dims(&bytes).or_else(|| gif_dims(&bytes)).or_else(|| jpeg_dims(&bytes));
    match dims {
        Some((w, h)) => format!("image: {} — {}×{} px, {} KB", path.display(), w, h, size_kb),
        None => format!("image: {} — {} KB", path.display(), size_kb),
    }
}

fn png_dims(b: &[u8]) -> Option<(u32, u32)> {
    if b.len() >= 24 && &b[0..8] == b"\x89PNG\r\n\x1a\n" {
        let w = u32::from_be_bytes([b[16], b[17], b[18], b[19]]);
        let h = u32::from_be_bytes([b[20], b[21], b[22], b[23]]);
        Some((w, h))
    } else {
        None
    }
}

fn gif_dims(b: &[u8]) -> Option<(u32, u32)> {
    if b.len() >= 10 && (&b[0..6] == b"GIF87a" || &b[0..6] == b"GIF89a") {
        let w = u16::from_le_bytes([b[6], b[7]]) as u32;
        let h = u16::from_le_bytes([b[8], b[9]]) as u32;
        Some((w, h))
    } else {
        None
    }
}

fn jpeg_dims(b: &[u8]) -> Option<(u32, u32)> {
    if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 {
        return None;
    }
    let mut i = 2;
    while i + 9 < b.len() {
        if b[i] != 0xFF {
            i += 1;
            continue;
        }
        let marker = b[i + 1];
        // SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 carry dimensions.
        if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
            let h = u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32;
            let w = u16::from_be_bytes([b[i + 7], b[i + 8]]) as u32;
            return Some((w, h));
        }
        let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
        i += 2 + len;
    }
    None
}

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

    #[test]
    fn reads_text_and_code() {
        let dir = std::env::temp_dir().join(format!("lucid-read-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let f = dir.join("x.rs");
        std::fs::write(&f, "fn main() {}\n").unwrap();
        assert!(read_file(&f).contains("fn main"));
        assert!(read_file(&dir.join("missing.txt")).contains("not found"));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn png_dimensions_parsed() {
        // 1×1 PNG header.
        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
        b.extend_from_slice(&[0, 0, 0, 13]); // IHDR len
        b.extend_from_slice(b"IHDR");
        b.extend_from_slice(&1u32.to_be_bytes());
        b.extend_from_slice(&1u32.to_be_bytes());
        assert_eq!(png_dims(&b), Some((1, 1)));
    }

    #[test]
    fn strips_docx_xml() {
        let xml = "<w:p><w:r><w:t>Hello</w:t></w:r></w:p><w:p><w:r><w:t>World</w:t></w:r></w:p>";
        let text = strip_xml(xml);
        assert!(text.contains("Hello"));
        assert!(text.contains("World"));
    }
}
