//! Metadata — document info dictionary, XMP, page layout/mode, PDF version.
//!
//! Zero `unsafe` in this file.

use crate::cpdf::document::CpdfDocument;
use crate::cpdf::error::{CpdfError, Result};
use std::collections::HashMap;

// ── Helpers ─────────────────────────────────────────────────────────

fn find_info_dict(text: &str) -> Option<(usize, usize)> {
    let trailer_idx = text.rfind("trailer")?;
    let trailer = &text[trailer_idx..];
    let info_idx = trailer.find("/Info")?;
    let rest = &trailer[info_idx + 5..];
    let trimmed = rest.trim_start();
    let end = trimmed
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(trimmed.len());
    let obj_num: usize = trimmed[..end].parse().ok()?;

    let needle = format!("{obj_num} 0 obj");
    let start = text.find(&needle)?;
    let dict_start = text[start..].find("<<")?;
    let dict_end = text[start + dict_start..].find(">>")?;
    Some((start + dict_start, start + dict_start + dict_end + 2))
}

fn extract_info_value(dict: &str, key: &str) -> Option<String> {
    let search = format!("/{key}");
    let idx = dict.find(&search)?;
    let rest = &dict[idx + search.len()..];
    let trimmed = rest.trim_start();

    if trimmed.starts_with('(') {
        let close = trimmed[1..].find(')')?;
        let val = &trimmed[1..1 + close];
        Some(
            val.replace("\\(", "(")
                .replace("\\)", ")")
                .replace("\\\\", "\\"),
        )
    } else if trimmed.starts_with('/') {
        let end = trimmed[1..]
            .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
            .unwrap_or(trimmed.len() - 1);
        Some(trimmed[1..1 + end].to_string())
    } else {
        let end = trimmed
            .find(|c: char| c == '/' || c == '>')
            .unwrap_or(trimmed.len());
        Some(trimmed[..end].trim().to_string())
    }
}

fn escape_pdf(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('(', "\\(")
        .replace(')', "\\)")
}

fn next_obj_num(text: &str) -> usize {
    let mut max = 0usize;
    let mut pos = 0;
    while let Some(idx) = text[pos..].find(" 0 obj") {
        let abs = pos + idx;
        let start = text[..abs].rfind(|c: char| !c.is_ascii_digit() && c != ' ');
        let num_str = match start {
            Some(s) => text[s + 1..abs].trim(),
            None => text[..abs].trim(),
        };
        if let Ok(n) = num_str.parse::<usize>() {
            if n > max {
                max = n;
            }
        }
        pos = abs + 6;
    }
    max + 1
}

fn rebuild_xref(data: &[u8]) -> Vec<u8> {
    crate::cpdf::bookmarks::rebuild_xref_data(data)
}

// ── Public API ──────────────────────────────────────────────────────

const INFO_KEYS: &[&str] = &[
    "Title",
    "Author",
    "Subject",
    "Keywords",
    "Creator",
    "Producer",
    "CreationDate",
    "ModDate",
];

/// Get a single info dictionary entry by key.
pub fn get_info(doc: &CpdfDocument, key: &str) -> Result<String> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);
    if let Some((ds, de)) = find_info_dict(&text) {
        let dict = &text[ds..de];
        Ok(extract_info_value(dict, key).unwrap_or_default())
    } else {
        Ok(String::new())
    }
}

/// Get all info dictionary entries as a HashMap.
pub fn get_all_info(doc: &CpdfDocument) -> Result<HashMap<String, String>> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);
    let mut map = HashMap::new();

    if let Some((ds, de)) = find_info_dict(&text) {
        let dict = &text[ds..de];
        for key in INFO_KEYS {
            if let Some(val) = extract_info_value(dict, key) {
                if !val.is_empty() {
                    map.insert(key.to_string(), val);
                }
            }
        }
    }

    Ok(map)
}

/// Set an info dictionary entry. Creates the /Info object if missing.
pub fn set_info(doc: CpdfDocument, key: &str, value: &str) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let mut text = String::from_utf8_lossy(&bytes).to_string();

    if let Some((ds, de)) = find_info_dict(&text) {
        let dict = text[ds..de].to_string();
        let search = format!("/{key}");
        let new_entry = format!("/{key} ({})", escape_pdf(value));

        let updated = if let Some(idx) = dict.find(&search) {
            let rest = &dict[idx + search.len()..];
            let trimmed = rest.trim_start();
            let val_end = if trimmed.starts_with('(') {
                trimmed[1..].find(')').unwrap_or(trimmed.len()) + 2
            } else {
                trimmed
                    .find(|c: char| c == '/' || c == '>')
                    .unwrap_or(trimmed.len())
            };
            let offset = rest.len() - trimmed.len();
            format!(
                "{}{} {}",
                &dict[..idx],
                new_entry,
                &dict[idx + search.len() + offset + val_end..]
            )
        } else {
            let close = dict.rfind(">>").unwrap_or(dict.len());
            format!("{}{}\n>>", &dict[..close], new_entry)
        };

        text = format!("{}{}{}", &text[..ds], updated, &text[de..]);
    } else {
        // Create a new /Info object
        let obj_num = next_obj_num(&text);
        let info_obj = format!(
            "{obj_num} 0 obj\n<< /{key} ({}) >>\nendobj\n\n",
            escape_pdf(value)
        );
        let insert_pos = text
            .rfind("xref")
            .or_else(|| text.rfind("startxref"))
            .unwrap_or(text.len());
        text.insert_str(insert_pos, &info_obj);

        // Add /Info to trailer
        if let Some(trailer_idx) = text.rfind("trailer") {
            let trailer_end = text[trailer_idx..].find(">>").unwrap_or(0) + trailer_idx;
            text.insert_str(trailer_end, &format!(" /Info {obj_num} 0 R"));
        }
    }

    let final_bytes = rebuild_xref(text.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

/// Remove the entire /Info dictionary.
pub fn remove_info(doc: CpdfDocument) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let mut text = String::from_utf8_lossy(&bytes).to_string();

    // Remove /Info reference from trailer
    if let Some(trailer_idx) = text.rfind("trailer") {
        let trailer_region = text[trailer_idx..].to_string();
        if let Some(info_idx) = trailer_region.find("/Info") {
            let rest = &trailer_region[info_idx + 5..];
            let trimmed = rest.trim_start();
            let end = trimmed
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(trimmed.len());
            let offset = rest.len() - trimmed.len();
            let remove_start = trailer_idx + info_idx;
            let remove_end = trailer_idx + info_idx + 5 + offset + end;
            text = format!("{}{}", &text[..remove_start], &text[remove_end..]);
        }
    }

    let final_bytes = rebuild_xref(text.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

/// Get raw XMP metadata XML, or None if not present.
/// Only returns XMP that is referenced from the catalog via /Metadata.
pub fn get_xmp(doc: &CpdfDocument) -> Result<Option<String>> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);

    let cat_idx = match text
        .find("/Type /Catalog")
        .or_else(|| text.find("/Type/Catalog"))
    {
        Some(i) => i,
        None => return Ok(None),
    };
    let cat_region = &text[cat_idx..];
    let meta_ref = match cat_region.find("/Metadata") {
        Some(i) => i,
        None => return Ok(None),
    };
    let rest = cat_region[meta_ref + 9..].trim_start();
    let obj_end = rest
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(rest.len());
    let _obj_num: usize = match rest[..obj_end].parse() {
        Ok(n) => n,
        Err(_) => return Ok(None),
    };

    if let Some(start) = text.find("<x:xmpmeta") {
        if let Some(end) = text[start..].find("</x:xmpmeta>") {
            return Ok(Some(text[start..start + end + 13].to_string()));
        }
    }
    Ok(None)
}

/// Set XMP metadata XML (replaces existing metadata stream or creates new one).
pub fn set_xmp(doc: CpdfDocument, xml: &str) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let mut text = String::from_utf8_lossy(&bytes).to_string();

    // Check for existing /Metadata reference in catalog
    if text.contains("/Metadata") {
        // For now, replace the existing XMP in-place is complex; stub.
        return Err(CpdfError::Unsupported(
            "replacing existing XMP metadata not yet implemented".into(),
        ));
    }

    // Create new metadata stream object
    let obj_num = next_obj_num(&text);
    let xmp_stream = format!(
        "{obj_num} 0 obj\n<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xml}\nendstream\nendobj\n\n",
        xml.len()
    );

    let insert_pos = text
        .rfind("xref")
        .or_else(|| text.rfind("startxref"))
        .unwrap_or(text.len());
    text.insert_str(insert_pos, &xmp_stream);

    // Add /Metadata to catalog
    if let Some(cat_idx) = text
        .find("/Type /Catalog")
        .or_else(|| text.find("/Type/Catalog"))
    {
        let dict_end = text[cat_idx..].find(">>").unwrap_or(0) + cat_idx;
        text.insert_str(dict_end, &format!(" /Metadata {obj_num} 0 R"));
    }

    let final_bytes = rebuild_xref(text.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

/// Remove XMP metadata.
pub fn remove_xmp(doc: CpdfDocument) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let mut text = String::from_utf8_lossy(&bytes).to_string();

    // Remove /Metadata reference from catalog
    if let Some(cat_idx) = text
        .find("/Type /Catalog")
        .or_else(|| text.find("/Type/Catalog"))
    {
        let cat_region = &text[cat_idx..];
        if let Some(meta_idx) = cat_region.find("/Metadata") {
            let rest = &cat_region[meta_idx + 9..];
            let trimmed = rest.trim_start();
            let end = trimmed
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(trimmed.len());
            let offset = rest.len() - trimmed.len();
            let remove_start = cat_idx + meta_idx;
            let remove_end = cat_idx + meta_idx + 9 + offset + end;
            text = format!("{}{}", &text[..remove_start], &text[remove_end..]);
        }
    }

    let final_bytes = rebuild_xref(text.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

/// Set the PDF version in the header.
pub fn set_pdf_version(doc: CpdfDocument, major: u8, minor: u8) -> Result<CpdfDocument> {
    let mut bytes = doc.to_bytes();
    let header = format!("%PDF-{major}.{minor}");
    if bytes.len() >= 8 {
        let header_bytes = header.as_bytes();
        let end = bytes.iter().position(|&b| b == b'\n').unwrap_or(8);
        let mut new = Vec::with_capacity(bytes.len());
        new.extend_from_slice(header_bytes);
        new.extend_from_slice(&bytes[end..]);
        bytes = new;
    }
    CpdfDocument::from_bytes(bytes)
}

/// Get /PageLayout from catalog.
pub fn get_page_layout(doc: &CpdfDocument) -> Result<String> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);
    if let Some(idx) = text.find("/PageLayout") {
        let rest = &text[idx + 11..];
        let trimmed = rest.trim_start();
        if trimmed.starts_with('/') {
            let end = trimmed[1..]
                .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
                .unwrap_or(trimmed.len() - 1);
            return Ok(trimmed[1..1 + end].to_string());
        }
    }
    Ok("SinglePage".to_string())
}

/// Set /PageLayout in catalog.
pub fn set_page_layout(doc: CpdfDocument, layout: &str) -> Result<CpdfDocument> {
    set_catalog_entry(doc, "PageLayout", &format!("/{layout}"))
}

/// Get /PageMode from catalog.
pub fn get_page_mode(doc: &CpdfDocument) -> Result<String> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);
    if let Some(idx) = text.find("/PageMode") {
        let rest = &text[idx + 9..];
        let trimmed = rest.trim_start();
        if trimmed.starts_with('/') {
            let end = trimmed[1..]
                .find(|c: char| c.is_whitespace() || c == '/' || c == '>')
                .unwrap_or(trimmed.len() - 1);
            return Ok(trimmed[1..1 + end].to_string());
        }
    }
    Ok("UseNone".to_string())
}

/// Set /PageMode in catalog.
pub fn set_page_mode(doc: CpdfDocument, mode: &str) -> Result<CpdfDocument> {
    set_catalog_entry(doc, "PageMode", &format!("/{mode}"))
}

fn set_catalog_entry(doc: CpdfDocument, key: &str, value: &str) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let mut text = String::from_utf8_lossy(&bytes).to_string();

    let cat_idx = text
        .find("/Type /Catalog")
        .or_else(|| text.find("/Type/Catalog"))
        .ok_or_else(|| CpdfError::Parse("catalog not found".into()))?;

    let dict_start = text[..cat_idx].rfind("<<").unwrap_or(cat_idx);
    let dict_end_offset = text[cat_idx..].find(">>").unwrap_or(0);
    let dict_end = cat_idx + dict_end_offset + 2;
    let dict = text[dict_start..dict_end].to_string();

    let search = format!("/{key}");
    let updated = if let Some(idx) = dict.find(&search) {
        let rest = &dict[idx + search.len()..];
        let trimmed = rest.trim_start();
        let end = trimmed
            .find(|c: char| c == '/' || c == '>')
            .unwrap_or(trimmed.len());
        let offset = rest.len() - trimmed.len();
        format!(
            "{}/{key} {} {}",
            &dict[..idx],
            value,
            &dict[idx + search.len() + offset + end..]
        )
    } else {
        let close = dict.rfind(">>").unwrap_or(dict.len());
        format!("{} /{key} {}\n>>", &dict[..close], value)
    };

    text = format!("{}{}{}", &text[..dict_start], updated, &text[dict_end..]);

    let final_bytes = rebuild_xref(text.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

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

    fn blank() -> CpdfDocument {
        CpdfDocument::blank(612.0, 792.0, 1).unwrap()
    }

    #[test]
    fn blank_has_no_info() {
        let info = get_all_info(&blank()).unwrap();
        assert!(info.is_empty());
    }

    #[test]
    fn set_and_get_info() {
        let doc = set_info(blank(), "Title", "My Document").unwrap();
        assert_eq!(get_info(&doc, "Title").unwrap(), "My Document");
    }

    #[test]
    fn remove_info_clears_all() {
        let doc = set_info(blank(), "Title", "Test").unwrap();
        let doc = remove_info(doc).unwrap();
        assert_eq!(get_info(&doc, "Title").unwrap(), "");
    }

    #[test]
    fn set_pdf_version() {
        let doc = super::set_pdf_version(blank(), 2, 0).unwrap();
        assert_eq!(doc.version(), "2.0");
    }

    #[test]
    fn default_page_layout() {
        assert_eq!(get_page_layout(&blank()).unwrap(), "SinglePage");
    }

    #[test]
    fn set_and_get_page_layout() {
        let doc = set_page_layout(blank(), "TwoColumnLeft").unwrap();
        assert_eq!(get_page_layout(&doc).unwrap(), "TwoColumnLeft");
    }

    #[test]
    fn set_and_get_page_mode() {
        let doc = set_page_mode(blank(), "UseOutlines").unwrap();
        assert_eq!(get_page_mode(&doc).unwrap(), "UseOutlines");
    }

    #[test]
    fn blank_has_no_xmp() {
        assert!(get_xmp(&blank()).unwrap().is_none());
    }
}
