//! Bookmarks (outlines) — list, add, remove, and serialise to/from JSON.
//!
//! PDF outlines live in the `/Outlines` dictionary referenced from the
//! document catalog.  This module parses that tree and provides a flat
//! `Vec<Bookmark>` representation that maps neatly onto the cpdf API.
//!
//! Zero `unsafe` in this file.

use crate::cpdf::document::CpdfDocument;
use crate::cpdf::error::{CpdfError, Result};
use serde::{Deserialize, Serialize};

// ── Bookmark type ───────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bookmark {
    /// Nesting level.  0 = top-level.
    pub level: usize,
    /// Target page (1-indexed).
    pub page: usize,
    /// Bookmark title (UTF-8).
    pub title: String,
    /// Whether children are expanded by default.
    pub open: bool,
}

impl Bookmark {
    pub fn new(title: &str, page: usize, level: usize) -> Self {
        Self {
            level,
            page,
            title: title.to_owned(),
            open: false,
        }
    }
}

// ── Parsing helpers ─────────────────────────────────────────────────

fn find_catalog(text: &str) -> Option<(usize, usize)> {
    let idx = text.find("/Type /Catalog")?;
    let obj_start = text[..idx].rfind("<<")?;
    let obj_end_offset = text[idx..].find(">>")?;
    Some((obj_start, idx + obj_end_offset + 2))
}

fn find_outlines_ref(catalog: &str) -> Option<usize> {
    let idx = catalog.find("/Outlines")?;
    let rest = &catalog[idx + 9..];
    let trimmed = rest.trim_start();
    let end = trimmed
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(trimmed.len());
    trimmed[..end].parse::<usize>().ok()
}

fn find_object(text: &str, obj_num: usize) -> Option<(usize, usize)> {
    let needle = format!("{obj_num} 0 obj");
    let start = text.find(&needle)?;
    let dict_start = text[start..].find("<<")?;
    let end_offset = text[start + dict_start..].find(">>")?;
    Some((start + dict_start, start + dict_start + end_offset + 2))
}

fn dict_int(dict: &str, key: &str) -> Option<usize> {
    let idx = dict.find(key)?;
    let rest = &dict[idx + key.len()..];
    let trimmed = rest.trim_start();
    let end = trimmed
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(trimmed.len());
    trimmed[..end].parse::<usize>().ok()
}

fn dict_ref(dict: &str, key: &str) -> Option<usize> {
    let idx = dict.find(key)?;
    let rest = &dict[idx + key.len()..];
    let trimmed = rest.trim_start();
    let end = trimmed
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(trimmed.len());
    if end == 0 {
        return None;
    }
    let num = trimmed[..end].parse::<usize>().ok()?;
    let after = trimmed[end..].trim_start();
    if after.starts_with("0 R") {
        Some(num)
    } else {
        None
    }
}

fn dict_string(dict: &str, key: &str) -> Option<String> {
    let idx = dict.find(key)?;
    let rest = &dict[idx + key.len()..];
    let open = rest.find('(')?;
    let close = rest[open + 1..].find(')')?;
    let raw = &rest[open + 1..open + 1 + close];
    Some(
        raw.replace("\\(", "(")
            .replace("\\)", ")")
            .replace("\\\\", "\\"),
    )
}

fn dest_page(text: &str, dict: &str) -> Option<usize> {
    if let Some(idx) = dict.find("/Dest") {
        let rest = &dict[idx + 5..];
        let trimmed = rest.trim_start();
        if trimmed.starts_with('[') {
            let end = trimmed.find(']').unwrap_or(trimmed.len());
            let inside = &trimmed[1..end].trim();
            let ref_end = inside
                .find(|c: char| !c.is_ascii_digit())
                .unwrap_or(inside.len());
            if ref_end > 0 {
                let page_obj = inside[..ref_end].parse::<usize>().ok()?;
                return page_obj_to_page_number(text, page_obj);
            }
        }
    }
    None
}

fn page_obj_to_page_number(text: &str, page_obj_num: usize) -> Option<usize> {
    let mut page_num = 0usize;
    let mut pos = 0;
    while let Some(idx) = text[pos..].find("/Type /Page") {
        let abs = pos + idx;
        let after = &text[abs + 11..abs + 14.min(text.len() - abs)];
        if after.starts_with('s') {
            pos = abs + 11;
            continue;
        }
        page_num += 1;
        let obj_start = text[..abs].rfind(" 0 obj").unwrap_or(0);
        let num_start = text[..obj_start].rfind(|c: char| !c.is_ascii_digit() && c != ' ');
        let num_str = match num_start {
            Some(ns) => text[ns + 1..obj_start].trim(),
            None => text[..obj_start].trim(),
        };
        if let Ok(n) = num_str.parse::<usize>() {
            if n == page_obj_num {
                return Some(page_num);
            }
        }
        pos = abs + 11;
    }
    None
}

fn collect_outlines(text: &str, first_obj: usize, level: usize) -> Vec<Bookmark> {
    let mut result = Vec::new();
    let mut current = Some(first_obj);

    while let Some(obj_num) = current {
        let Some((_start, end)) = find_object(text, obj_num) else {
            break;
        };
        let dict = &text[_start..end];

        let title = dict_string(dict, "/Title").unwrap_or_default();
        let page = dest_page(text, dict).unwrap_or(1);
        let count = dict_int(dict, "/Count");
        let is_open = count.map_or(false, |c| c > 0);

        result.push(Bookmark {
            level,
            page,
            title,
            open: is_open,
        });

        if let Some(child_ref) = dict_ref(dict, "/First") {
            let children = collect_outlines(text, child_ref, level + 1);
            result.extend(children);
        }

        current = dict_ref(dict, "/Next");
    }

    result
}

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

/// List all bookmarks in the document.
pub fn list_bookmarks(doc: &CpdfDocument) -> Result<Vec<Bookmark>> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);

    let Some((_cs, ce)) = find_catalog(&text) else {
        return Ok(Vec::new());
    };
    let catalog = &text[_cs..ce];

    let Some(outline_obj) = find_outlines_ref(catalog) else {
        return Ok(Vec::new());
    };

    let Some((_os, oe)) = find_object(&text, outline_obj) else {
        return Ok(Vec::new());
    };
    let outlines_dict = &text[_os..oe];

    let Some(first_ref) = dict_ref(outlines_dict, "/First") else {
        return Ok(Vec::new());
    };

    Ok(collect_outlines(&text, first_ref, 0))
}

/// Serialize bookmarks to JSON.
pub fn get_bookmarks_json(doc: &CpdfDocument) -> Result<String> {
    let bms = list_bookmarks(doc)?;
    serde_json::to_string_pretty(&bms).map_err(|e| CpdfError::Parse(e.to_string()))
}

/// Replace all bookmarks from a JSON string.
pub fn set_bookmarks_from_json(doc: CpdfDocument, json: &str) -> Result<CpdfDocument> {
    let bookmarks: Vec<Bookmark> =
        serde_json::from_str(json).map_err(|e| CpdfError::Parse(e.to_string()))?;
    set_bookmarks(doc, &bookmarks)
}

/// Remove all bookmarks from the document.
pub fn remove_bookmarks(doc: CpdfDocument) -> Result<CpdfDocument> {
    set_bookmarks(doc, &[])
}

/// Add a single bookmark to the end of the existing bookmark list.
pub fn add_bookmark(doc: CpdfDocument, bm: Bookmark) -> Result<CpdfDocument> {
    let mut bms = list_bookmarks(&doc)?;
    bms.push(bm);
    set_bookmarks(doc, &bms)
}

/// Add a top-level bookmark pointing to page 1.
pub fn add_bookmark_title(doc: CpdfDocument, title: &str) -> Result<CpdfDocument> {
    add_bookmark(
        doc,
        Bookmark {
            level: 0,
            page: 1,
            title: title.to_owned(),
            open: false,
        },
    )
}

/// Set all bookmarks' open state to `true` for levels <= `level`, `false`
/// for deeper levels.
pub fn bookmarks_open_to_level(doc: CpdfDocument, level: usize) -> Result<CpdfDocument> {
    let mut bms = list_bookmarks(&doc)?;
    for bm in &mut bms {
        bm.open = bm.level < level;
    }
    set_bookmarks(doc, &bms)
}

// ── Internal: write bookmarks back into PDF ─────────────────────────

fn get_page_obj_nums(text: &str) -> Vec<usize> {
    let mut nums = Vec::new();
    let mut pos = 0;
    while let Some(idx) = text[pos..].find("/Type /Page") {
        let abs = pos + idx;
        let after = &text[abs + 11..abs + 14.min(text.len() - abs)];
        if after.starts_with('s') {
            pos = abs + 11;
            continue;
        }
        let obj_start = text[..abs].rfind(" 0 obj").unwrap_or(0);
        let num_start = text[..obj_start].rfind(|c: char| !c.is_ascii_digit() && c != ' ');
        let num_str = match num_start {
            Some(ns) => text[ns + 1..obj_start].trim(),
            None => text[..obj_start].trim(),
        };
        if let Ok(n) = num_str.parse::<usize>() {
            nums.push(n);
        }
        pos = abs + 11;
    }
    nums
}

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 escape_pdf(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace('(', "\\(")
        .replace(')', "\\)")
}

/// Core routine: replace the entire outline tree with the given bookmarks.
fn set_bookmarks(doc: CpdfDocument, bookmarks: &[Bookmark]) -> Result<CpdfDocument> {
    let bytes = doc.to_bytes();
    let text = String::from_utf8_lossy(&bytes);
    let page_objs = get_page_obj_nums(&text);
    let mut obj_num = next_obj_num(&text);

    if bookmarks.is_empty() {
        return remove_outlines_from_catalog(&text);
    }

    // Build a tree of object references.
    // Flat list → we group by level.  Each bookmark gets an object number.
    struct BmObj {
        obj_num: usize,
        bm: Bookmark,
        children: Vec<usize>,
        next: Option<usize>,
        prev: Option<usize>,
        parent: usize,
    }

    let outlines_obj = obj_num;
    obj_num += 1;

    let mut bm_objs: Vec<BmObj> = Vec::new();
    for bm in bookmarks {
        bm_objs.push(BmObj {
            obj_num,
            bm: bm.clone(),
            children: Vec::new(),
            next: None,
            prev: None,
            parent: outlines_obj,
        });
        obj_num += 1;
    }

    // Build parent-child and sibling relationships using the level field.
    // We use a stack to track the current parent at each level.
    let mut parent_stack: Vec<usize> = Vec::new(); // indices into bm_objs

    for i in 0..bm_objs.len() {
        let level = bm_objs[i].bm.level;

        // Pop stack until we find a node whose level < current
        while let Some(&top_idx) = parent_stack.last() {
            if bm_objs[top_idx].bm.level < level {
                break;
            }
            parent_stack.pop();
        }

        if let Some(&parent_idx) = parent_stack.last() {
            bm_objs[i].parent = bm_objs[parent_idx].obj_num;
            bm_objs[parent_idx].children.push(i);
        } else {
            bm_objs[i].parent = outlines_obj;
        }

        parent_stack.push(i);
    }

    // Set next/prev within sibling groups.
    // Top-level siblings: those whose parent == outlines_obj
    set_siblings(&mut bm_objs, outlines_obj);
    let all_parents: Vec<usize> = bm_objs.iter().map(|b| b.obj_num).collect();
    for p in &all_parents {
        set_siblings(&mut bm_objs, *p);
    }

    fn set_siblings(objs: &mut [BmObj], parent_obj: usize) {
        let indices: Vec<usize> = objs
            .iter()
            .enumerate()
            .filter(|(_, b)| b.parent == parent_obj)
            .map(|(i, _)| i)
            .collect();
        for (pos, &idx) in indices.iter().enumerate() {
            if pos + 1 < indices.len() {
                let next_obj = objs[indices[pos + 1]].obj_num;
                objs[idx].next = Some(next_obj);
            }
            if pos > 0 {
                let prev_obj = objs[indices[pos - 1]].obj_num;
                objs[idx].prev = Some(prev_obj);
            }
        }
    }

    // Find top-level first/last
    let top_level: Vec<usize> = bm_objs
        .iter()
        .enumerate()
        .filter(|(_, b)| b.parent == outlines_obj)
        .map(|(i, _)| i)
        .collect();
    let first_top = top_level.first().map(|&i| bm_objs[i].obj_num);
    let last_top = top_level.last().map(|&i| bm_objs[i].obj_num);

    // Build PDF objects.
    let mut new_objects = String::new();

    // Outlines root
    new_objects.push_str(&format!("{outlines_obj} 0 obj\n"));
    new_objects.push_str("<< /Type /Outlines\n");
    if let Some(f) = first_top {
        new_objects.push_str(&format!("   /First {f} 0 R\n"));
    }
    if let Some(l) = last_top {
        new_objects.push_str(&format!("   /Last {l} 0 R\n"));
    }
    new_objects.push_str(&format!("   /Count {}\n", top_level.len()));
    new_objects.push_str(">>\nendobj\n\n");

    // Bookmark objects
    for bm_obj in &bm_objs {
        let page_idx = bm_obj.bm.page.saturating_sub(1);
        let page_ref = page_objs
            .get(page_idx)
            .copied()
            .unwrap_or(page_objs.first().copied().unwrap_or(1));

        new_objects.push_str(&format!("{} 0 obj\n", bm_obj.obj_num));
        new_objects.push_str("<< /Title (");
        new_objects.push_str(&escape_pdf(&bm_obj.bm.title));
        new_objects.push_str(")\n");
        new_objects.push_str(&format!("   /Parent {} 0 R\n", bm_obj.parent));
        new_objects.push_str(&format!("   /Dest [{page_ref} 0 R /Fit]\n"));

        if let Some(next) = bm_obj.next {
            new_objects.push_str(&format!("   /Next {next} 0 R\n"));
        }
        if let Some(prev) = bm_obj.prev {
            new_objects.push_str(&format!("   /Prev {prev} 0 R\n"));
        }

        let child_indices: Vec<usize> = bm_objs
            .iter()
            .enumerate()
            .filter(|(_, b)| b.parent == bm_obj.obj_num)
            .map(|(i, _)| i)
            .collect();
        if !child_indices.is_empty() {
            let first_child = bm_objs[child_indices[0]].obj_num;
            let last_child = bm_objs[*child_indices.last().unwrap()].obj_num;
            new_objects.push_str(&format!("   /First {first_child} 0 R\n"));
            new_objects.push_str(&format!("   /Last {last_child} 0 R\n"));
            let count = child_indices.len();
            if bm_obj.bm.open {
                new_objects.push_str(&format!("   /Count {count}\n"));
            } else {
                new_objects.push_str(&format!("   /Count -{count}\n"));
            }
        }

        new_objects.push_str(">>\nendobj\n\n");
    }

    // Inject objects before the xref table and update catalog.
    let mut result = text.to_string();

    // Remove old /Outlines reference from catalog (if any) and add new one.
    if let Some((cs, ce)) = find_catalog(&result) {
        let catalog = result[cs..ce].to_string();
        let updated_catalog = if let Some(out_idx) = catalog.find("/Outlines") {
            let after = &catalog[out_idx + 9..];
            let end = after
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(after.len());
            format!(
                "{}/Outlines {outlines_obj} 0 R {}",
                &catalog[..out_idx],
                &after[end..]
            )
        } else {
            let close = catalog.rfind(">>").unwrap_or(catalog.len());
            format!("{}/Outlines {outlines_obj} 0 R\n>>", &catalog[..close])
        };
        result = format!("{}{}{}", &result[..cs], updated_catalog, &result[ce..]);
    }

    // Insert new objects before xref/startxref.
    let insert_pos = result
        .rfind("xref")
        .or_else(|| result.rfind("startxref"))
        .unwrap_or(result.len());
    result.insert_str(insert_pos, &new_objects);

    // Rebuild xref
    let final_bytes = rebuild_xref(result.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

fn remove_outlines_from_catalog(text: &str) -> Result<CpdfDocument> {
    let mut result = text.to_string();
    if let Some((cs, ce)) = find_catalog(&result) {
        let catalog = result[cs..ce].to_string();
        if let Some(out_idx) = catalog.find("/Outlines") {
            let after = &catalog[out_idx + 9..];
            let end = after
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(after.len());
            let cleaned = format!("{}{}", &catalog[..out_idx], &after[end..]);
            result = format!("{}{}{}", &result[..cs], cleaned, &result[ce..]);
        }
    }
    let final_bytes = rebuild_xref(result.as_bytes());
    CpdfDocument::from_bytes(final_bytes)
}

fn rebuild_xref(data: &[u8]) -> Vec<u8> {
    let text = String::from_utf8_lossy(data);

    // Collect all object offsets.
    let mut entries: Vec<(usize, usize)> = Vec::new();
    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>() {
            let line_start = start.map(|s| s + 1).unwrap_or(0);
            entries.push((n, line_start));
        }
        pos = abs + 6;
    }

    let max_obj = entries.iter().map(|(n, _)| *n).max().unwrap_or(0);

    // Truncate at existing xref (if any).
    let body_end = text
        .rfind("xref")
        .or_else(|| text.rfind("startxref"))
        .unwrap_or(text.len());
    let mut output = data[..body_end].to_vec();

    // Write xref table.
    let xref_offset = output.len();
    output.extend_from_slice(b"xref\n");
    output.extend_from_slice(format!("0 {}\n", max_obj + 1).as_bytes());
    output.extend_from_slice(b"0000000000 65535 f \n");

    let mut offset_map = vec![None; max_obj + 1];
    for &(num, offset) in &entries {
        if num <= max_obj {
            offset_map[num] = Some(offset);
        }
    }
    for i in 1..=max_obj {
        match offset_map[i] {
            Some(off) => {
                output.extend_from_slice(format!("{:010} 00000 n \n", off).as_bytes());
            }
            None => {
                output.extend_from_slice(b"0000000000 00000 f \n");
            }
        }
    }

    // Preserve extra trailer entries (/Info, /Encrypt, etc.) from original.
    let mut extra_trailer = String::new();
    if let Some(t_idx) = text.rfind("trailer") {
        let t_region = &text[t_idx..body_end.min(text.len())];
        if let Some(dict_start) = t_region.find("<<") {
            let dict_end = t_region[dict_start..].find(">>").unwrap_or(t_region.len()) + dict_start;
            let dict_body = &t_region[dict_start + 2..dict_end];
            for entry_key in ["/Info", "/Encrypt", "/ID"] {
                if let Some(ki) = dict_body.find(entry_key) {
                    let rest = &dict_body[ki..];
                    let end = rest[1..]
                        .find(|c: char| c == '/')
                        .map(|i| i + 1)
                        .unwrap_or(rest.len());
                    let entry = rest[..end].trim_end();
                    extra_trailer.push(' ');
                    extra_trailer.push_str(entry);
                }
            }
        }
    }

    output.extend_from_slice(b"trailer\n");
    output.extend_from_slice(
        format!("<< /Size {} /Root 1 0 R{extra_trailer} >>\n", max_obj + 1).as_bytes(),
    );
    output.extend_from_slice(b"startxref\n");
    output.extend_from_slice(format!("{xref_offset}\n").as_bytes());
    output.extend_from_slice(b"%%EOF\n");

    output
}

/// Rebuild xref table from raw bytes. Exposed for sibling cpdf modules.
pub fn rebuild_xref_data(data: &[u8]) -> Vec<u8> {
    rebuild_xref(data)
}

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

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

    #[test]
    fn blank_has_no_bookmarks() {
        assert!(list_bookmarks(&blank()).unwrap().is_empty());
    }

    #[test]
    fn add_and_list_bookmark() {
        let doc = blank();
        let doc = add_bookmark(doc, Bookmark::new("First", 1, 0)).unwrap();
        let bms = list_bookmarks(&doc).unwrap();
        assert_eq!(bms.len(), 1);
        assert_eq!(bms[0].title, "First");
        assert_eq!(bms[0].page, 1);
    }

    #[test]
    fn add_bookmark_title_convenience() {
        let doc = blank();
        let doc = add_bookmark_title(doc, "My Title").unwrap();
        let bms = list_bookmarks(&doc).unwrap();
        assert_eq!(bms.len(), 1);
        assert_eq!(bms[0].title, "My Title");
    }

    #[test]
    fn remove_bookmarks_clears_all() {
        let doc = blank();
        let doc = add_bookmark(doc, Bookmark::new("A", 1, 0)).unwrap();
        let doc = remove_bookmarks(doc).unwrap();
        assert!(list_bookmarks(&doc).unwrap().is_empty());
    }

    #[test]
    fn json_roundtrip() {
        let doc = blank();
        let doc = add_bookmark(doc, Bookmark::new("Ch1", 1, 0)).unwrap();
        let doc = add_bookmark(doc, Bookmark::new("Sec1.1", 2, 1)).unwrap();

        let json = get_bookmarks_json(&doc).unwrap();
        let doc2 = set_bookmarks_from_json(blank(), &json).unwrap();
        let bms = list_bookmarks(&doc2).unwrap();

        assert_eq!(bms.len(), 2);
        assert_eq!(bms[0].title, "Ch1");
        assert_eq!(bms[1].title, "Sec1.1");
    }

    #[test]
    fn bookmarks_open_to_level_sets_open() {
        let doc = blank();
        let doc = add_bookmark(doc, Bookmark::new("A", 1, 0)).unwrap();
        let doc = add_bookmark(doc, Bookmark::new("B", 1, 1)).unwrap();
        let doc = bookmarks_open_to_level(doc, 1).unwrap();

        let bms = list_bookmarks(&doc).unwrap();
        assert!(bms[0].open);
        assert!(!bms[1].open);
    }
}
