//! Interactive PDF features: actions, destinations, triggers
//!
//! Provides parsing and resolution for:
//! - Action dictionaries (GoTo, URI, Launch, etc.)
//! - Destinations (page + zoom type)
//! - Additional actions (/AA) and trigger mapping
//! - Named destinations (/Dests, /Names)

use crate::fitz::error::{Error, Result};
use std::collections::HashMap;

use super::document::PdfDocument;
use super::object::{Dict, Name, ObjRef, Object};

// ============================================================================
// Action
// ============================================================================

/// PDF action types per PDF specification
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
    /// GoTo: navigate to destination in current document
    GoTo { dest: Destination },
    /// GoToR: navigate to destination in remote document
    GoToR {
        file_spec: String,
        dest: Destination,
        new_window: bool,
    },
    /// Launch: open external file or application
    Launch { file_spec: String, new_window: bool },
    /// URI: resolve web link
    URI { uri: String, is_map: bool },
    /// Named: predefined action (e.g. NextPage, PrevPage)
    Named { name: String },
    /// JavaScript: execute script
    JavaScript { script: String },
    /// SubmitForm: send form data to URL
    SubmitForm { url: String, flags: u32 },
    /// ResetForm: reset form fields
    ResetForm { fields: Vec<String>, flags: u32 },
    /// ImportData: import FDF data
    ImportData { file_spec: String },
    /// Hide: show/hide annotations or fields
    Hide {
        targets: Vec<HideTarget>,
        hide: bool,
    },
    /// SetOCGState: set optional content group state
    SetOCGState {
        state: Vec<OcgStateEntry>,
        preserve: bool,
    },
    /// Rendition: control media
    Rendition { op: u32, media: Option<String> },
    /// Trans: transition effect
    Trans {
        duration: f64,
        style: Option<String>,
    },
    /// GoTo3DView: 3D view
    GoTo3DView { view: Option<String> },
    /// Unknown or unsupported action type
    Unknown { subtype: String },
}

/// Target for Hide action
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HideTarget {
    Annotation(ObjRef),
    FieldName(String),
}

/// Optional content group state entry
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OcgStateEntry {
    On(ObjRef),
    Off(ObjRef),
    Toggle(ObjRef),
}

// ============================================================================
// Destination
// ============================================================================

/// Zoom type for PDF destinations
#[derive(Debug, Clone, PartialEq)]
pub enum ZoomType {
    /// Fit page to window
    Fit,
    /// Fit horizontally, top at y
    FitH { top: Option<f64> },
    /// Fit vertically, left at x
    FitV { left: Option<f64> },
    /// Fit rectangle to window
    FitR {
        left: f64,
        bottom: f64,
        right: f64,
        top: f64,
    },
    /// Fit bounding box to window
    FitB,
    /// Fit bounding box horizontally
    FitBH { top: Option<f64> },
    /// Fit bounding box vertically
    FitBV { left: Option<f64> },
    /// XYZ: left, top, zoom (None = no change)
    Xyz {
        left: Option<f64>,
        top: Option<f64>,
        zoom: Option<f64>,
    },
}

/// PDF destination: page reference + zoom
#[derive(Debug, Clone, PartialEq)]
pub struct Destination {
    /// Page object reference or page number (0-based index when resolved)
    pub page: DestinationPage,
    pub zoom: ZoomType,
}

/// Page reference in a destination
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DestinationPage {
    /// Indirect reference to page object
    Ref(ObjRef),
    /// Page number (0-based), used when resolved from page tree
    PageNumber(usize),
}

// ============================================================================
// Trigger
// ============================================================================

/// Additional action triggers (per PDF spec /AA)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Trigger {
    /// E: page/annotation enter
    Enter,
    /// X: page/annotation exit
    Exit,
    /// D: mouse down
    MouseDown,
    /// U: mouse up
    MouseUp,
    /// Fo: focus
    Focus,
    /// Bl: blur
    Blur,
    /// PO: page open
    PageOpen,
    /// PC: page close
    PageClose,
    /// PV: page visible
    PageVisible,
    /// PI: page invisible
    PageInvisible,
    /// K: keystroke
    Keystroke,
    /// F: format
    Format,
    /// V: validate
    Validate,
    /// C: calculate
    Calculate,
    /// WC: document close
    DocClose,
    /// WS: document save
    DocSave,
    /// DS: document save (duplicate key in some docs)
    DocSaveAs,
    /// WP: document print
    DocPrint,
}

impl Trigger {
    pub fn from_name(s: &str) -> Option<Self> {
        match s {
            "E" => Some(Self::Enter),
            "X" => Some(Self::Exit),
            "D" => Some(Self::MouseDown),
            "U" => Some(Self::MouseUp),
            "Fo" => Some(Self::Focus),
            "Bl" => Some(Self::Blur),
            "PO" => Some(Self::PageOpen),
            "PC" => Some(Self::PageClose),
            "PV" => Some(Self::PageVisible),
            "PI" => Some(Self::PageInvisible),
            "K" => Some(Self::Keystroke),
            "F" => Some(Self::Format),
            "V" => Some(Self::Validate),
            "C" => Some(Self::Calculate),
            "WC" => Some(Self::DocClose),
            "WS" => Some(Self::DocSave),
            "DS" => Some(Self::DocSaveAs),
            "WP" => Some(Self::DocPrint),
            _ => None,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Enter => "E",
            Self::Exit => "X",
            Self::MouseDown => "D",
            Self::MouseUp => "U",
            Self::Focus => "Fo",
            Self::Blur => "Bl",
            Self::PageOpen => "PO",
            Self::PageClose => "PC",
            Self::PageVisible => "PV",
            Self::PageInvisible => "PI",
            Self::Keystroke => "K",
            Self::Format => "F",
            Self::Validate => "V",
            Self::Calculate => "C",
            Self::DocClose => "WC",
            Self::DocSave => "WS",
            Self::DocSaveAs => "DS",
            Self::DocPrint => "WP",
        }
    }
}

/// Additional actions: maps triggers to actions
#[derive(Debug, Clone, Default)]
pub struct AdditionalActions {
    pub actions: HashMap<Trigger, Action>,
}

// ============================================================================
// Named Destinations
// ============================================================================

/// Named destinations from catalog /Dests or /Names
#[derive(Debug, Clone, Default)]
pub struct NamedDestinations {
    /// Map from name string to destination
    dests: HashMap<String, Destination>,
}

impl NamedDestinations {
    pub fn get(&self, name: &str) -> Option<&Destination> {
        self.dests.get(name)
    }

    pub fn insert(&mut self, name: String, dest: Destination) {
        self.dests.insert(name, dest);
    }
}

// ============================================================================
// Parsing Helpers
// ============================================================================

fn get_number(obj: &Object) -> Option<f64> {
    let v = match obj {
        Object::Int(i) => *i as f64,
        Object::Real(r) => *r,
        _ => return None,
    };
    if v.is_finite() { Some(v) } else { None }
}

fn get_string(obj: &Object) -> Option<String> {
    obj.as_string()
        .and_then(|s| s.as_str())
        .map(|s| s.to_string())
}

fn get_name_str(obj: &Object) -> Option<&str> {
    obj.as_name().map(|n| n.as_str())
}

// ============================================================================
// parse_destination
// ============================================================================

/// Parse a destination from PDF objects.
/// Accepts either:
/// - Array: [page_ref, /Fit] or [page_ref, /XYZ, left, top, zoom] etc.
/// - Name: named destination (resolved separately via resolve_destination)
pub fn parse_destination(obj: &Object) -> Result<Destination> {
    let arr = obj.as_array().ok_or_else(|| {
        Error::pdf("Destination must be array or name (name requires resolve_destination)")
    })?;

    if arr.len() < 2 {
        return Err(Error::pdf("Destination array too short"));
    }

    let page_ref = arr[0]
        .as_obj_ref()
        .map(DestinationPage::Ref)
        .ok_or_else(|| Error::pdf("Destination must start with page reference"))?;

    let zoom_name = arr[1]
        .as_name()
        .map(|n| n.as_str())
        .ok_or_else(|| Error::pdf("Destination zoom type must be name"))?;

    let zoom = match zoom_name {
        "Fit" => ZoomType::Fit,
        "FitH" => {
            let top = arr.get(2).and_then(get_number);
            ZoomType::FitH { top }
        }
        "FitV" => {
            let left = arr.get(2).and_then(get_number);
            ZoomType::FitV { left }
        }
        "FitR" => {
            let left = arr.get(2).and_then(get_number).unwrap_or(0.0);
            let bottom = arr.get(3).and_then(get_number).unwrap_or(0.0);
            let right = arr.get(4).and_then(get_number).unwrap_or(0.0);
            let top = arr.get(5).and_then(get_number).unwrap_or(0.0);
            ZoomType::FitR {
                left,
                bottom,
                right,
                top,
            }
        }
        "FitB" => ZoomType::FitB,
        "FitBH" => {
            let top = arr.get(2).and_then(get_number);
            ZoomType::FitBH { top }
        }
        "FitBV" => {
            let left = arr.get(2).and_then(get_number);
            ZoomType::FitBV { left }
        }
        "XYZ" => {
            let left = arr.get(2).and_then(get_number);
            let top = arr.get(3).and_then(get_number);
            let zoom = arr.get(4).and_then(get_number);
            ZoomType::Xyz { left, top, zoom }
        }
        _ => ZoomType::Fit,
    };

    Ok(Destination {
        page: page_ref,
        zoom,
    })
}

// ============================================================================
// parse_action
// ============================================================================

/// Parse an action dictionary from PDF objects.
/// The dict should be resolved (no indirect refs for nested structures).
pub fn parse_action(dict: &Dict, resolve: impl Fn(&Object) -> Object) -> Result<Action> {
    let subtype = dict
        .get(&Name::new("S"))
        .and_then(|o| o.as_name())
        .map(|n| n.as_str())
        .unwrap_or("Unknown");

    let action = match subtype {
        "GoTo" => {
            let dest_obj = dict
                .get(&Name::new("D"))
                .ok_or_else(|| Error::pdf("GoTo action missing /D"))?;
            let resolved = resolve(dest_obj);
            let dest = parse_destination(&resolved)?;
            Action::GoTo { dest }
        }
        "GoToR" => {
            let file_spec = dict
                .get(&Name::new("F"))
                .and_then(|o| {
                    let resolved = resolve(o);
                    get_string(&resolved).or_else(|| {
                        resolved.as_dict().and_then(|d| {
                            d.get(&Name::new("F")).and_then(|f| get_string(&resolve(f)))
                        })
                    })
                })
                .unwrap_or_default();
            let dest_obj = dict.get(&Name::new("D")).map(|o| resolve(o));
            let dest = dest_obj
                .as_ref()
                .map(|o| parse_destination(o))
                .transpose()?
                .unwrap_or(Destination {
                    page: DestinationPage::Ref(ObjRef::new(0, 0)),
                    zoom: ZoomType::Fit,
                });
            let new_window = dict
                .get(&Name::new("NewWindow"))
                .and_then(|o| o.as_bool())
                .unwrap_or(false);
            Action::GoToR {
                file_spec,
                dest,
                new_window,
            }
        }
        "Launch" => {
            let file_spec = dict
                .get(&Name::new("F"))
                .and_then(|o| {
                    let resolved = resolve(o);
                    get_string(&resolved).or_else(|| {
                        resolved.as_dict().and_then(|d| {
                            d.get(&Name::new("F")).and_then(|f| get_string(&resolve(f)))
                        })
                    })
                })
                .unwrap_or_default();
            let new_window = dict
                .get(&Name::new("NewWindow"))
                .and_then(|o| o.as_bool())
                .unwrap_or(false);
            Action::Launch {
                file_spec,
                new_window,
            }
        }
        "URI" => {
            let uri = dict
                .get(&Name::new("URI"))
                .and_then(|o| get_string(&resolve(o)))
                .unwrap_or_default();
            let is_map = dict
                .get(&Name::new("IsMap"))
                .and_then(|o| o.as_bool())
                .unwrap_or(false);
            Action::URI { uri, is_map }
        }
        "Named" => {
            let name = dict
                .get(&Name::new("N"))
                .and_then(|o| get_name_str(o))
                .map(|s| s.to_string())
                .unwrap_or_default();
            Action::Named { name }
        }
        "JavaScript" => {
            let script = dict
                .get(&Name::new("JS"))
                .and_then(|o| get_string(&resolve(o)))
                .unwrap_or_default();
            Action::JavaScript { script }
        }
        "SubmitForm" => {
            let url = dict
                .get(&Name::new("F"))
                .and_then(|o| get_string(&resolve(o)))
                .unwrap_or_default();
            let flags = dict
                .get(&Name::new("Flags"))
                .and_then(|o| o.as_int())
                .unwrap_or(0) as u32;
            Action::SubmitForm { url, flags }
        }
        "ResetForm" => {
            let fields = dict
                .get(&Name::new("Fields"))
                .and_then(|o| o.as_array())
                .map(|a| {
                    a.iter()
                        .filter_map(|o| get_name_str(o).map(|s| s.to_string()))
                        .collect()
                })
                .unwrap_or_default();
            let flags = dict
                .get(&Name::new("Flags"))
                .and_then(|o| o.as_int())
                .unwrap_or(0) as u32;
            Action::ResetForm { fields, flags }
        }
        "ImportData" => {
            let file_spec = dict
                .get(&Name::new("F"))
                .and_then(|o| get_string(&resolve(o)))
                .unwrap_or_default();
            Action::ImportData { file_spec }
        }
        "Hide" => {
            let targets = dict
                .get(&Name::new("T"))
                .and_then(|o| o.as_array())
                .map(|a| {
                    a.iter()
                        .filter_map(|o| {
                            if let Some(r) = o.as_obj_ref() {
                                Some(HideTarget::Annotation(r))
                            } else if let Some(s) =
                                get_string(o).or_else(|| get_name_str(o).map(String::from))
                            {
                                Some(HideTarget::FieldName(s))
                            } else {
                                None
                            }
                        })
                        .collect()
                })
                .unwrap_or_default();
            let hide = dict
                .get(&Name::new("H"))
                .and_then(|o| o.as_bool())
                .unwrap_or(true);
            Action::Hide { targets, hide }
        }
        "SetOCGState" => {
            let state = dict
                .get(&Name::new("State"))
                .and_then(|o| o.as_array())
                .map(|a| {
                    let mut state = Vec::new();
                    let mut i = 0;
                    while i + 1 < a.len() {
                        let name = get_name_str(&a[i]);
                        let r = a[i + 1].as_obj_ref();
                        if let (Some(name), Some(r)) = (name, r) {
                            match name {
                                "ON" => state.push(OcgStateEntry::On(r)),
                                "OFF" => state.push(OcgStateEntry::Off(r)),
                                "Toggle" => state.push(OcgStateEntry::Toggle(r)),
                                _ => {}
                            }
                        }
                        i += 2;
                    }
                    state
                })
                .unwrap_or_default();
            let preserve = dict
                .get(&Name::new("PreserveRB"))
                .and_then(|o| o.as_bool())
                .unwrap_or(false);
            Action::SetOCGState { state, preserve }
        }
        "Rendition" => {
            let op = dict
                .get(&Name::new("OP"))
                .and_then(|o| o.as_int())
                .unwrap_or(0) as u32;
            let media = dict
                .get(&Name::new("JS"))
                .and_then(|o| get_string(&resolve(o)));
            Action::Rendition { op, media }
        }
        "Trans" => {
            let duration = dict
                .get(&Name::new("D"))
                .and_then(get_number)
                .unwrap_or(1.0);
            let style = dict
                .get(&Name::new("Trans"))
                .and_then(|o| o.as_dict())
                .and_then(|d| d.get(&Name::new("S")).and_then(|o| get_name_str(o)))
                .map(|s| s.to_string());
            Action::Trans { duration, style }
        }
        "GoTo3DView" => {
            let view = dict.get(&Name::new("V")).and_then(|o| {
                let resolved = resolve(o);
                get_string(&resolved)
            });
            Action::GoTo3DView { view }
        }
        _ => Action::Unknown {
            subtype: subtype.to_string(),
        },
    };

    Ok(action)
}

// ============================================================================
// parse_additional_actions
// ============================================================================

/// Parse /AA dictionary into AdditionalActions.
/// Returns empty AdditionalActions if dict has no /AA.
pub fn parse_additional_actions(
    dict: &Dict,
    resolve: impl Fn(&Object) -> Object,
) -> Result<AdditionalActions> {
    let mut actions = AdditionalActions::default();

    let aa_resolved = match dict.get(&Name::new("AA")) {
        Some(o) => resolve(o),
        None => return Ok(actions),
    };

    let aa = match aa_resolved.as_dict() {
        Some(d) => d,
        None => return Ok(actions),
    };

    for (key, value) in aa {
        if let Some(trigger) = Trigger::from_name(key.as_str()) {
            let resolved = resolve(value);
            if let Some(action_dict) = resolved.as_dict() {
                if let Ok(action) = parse_action(action_dict, &resolve) {
                    actions.actions.insert(trigger, action);
                }
            }
        }
    }

    Ok(actions)
}

// ============================================================================
// NamedDestinations parsing
// ============================================================================

/// Parse named destinations from catalog /Dests or /Names
pub fn parse_named_destinations(
    catalog: &Dict,
    resolve: impl Fn(&Object) -> Object,
) -> Result<NamedDestinations> {
    let mut nd = NamedDestinations::default();

    // /Dests: direct dictionary (only resolve if indirect so page refs in arrays stay)
    if let Some(dests_obj) = catalog.get(&Name::new("Dests")) {
        let dests = if dests_obj.as_obj_ref().is_some() {
            resolve(dests_obj)
        } else {
            dests_obj.clone()
        };
        if let Some(dict) = dests.as_dict() {
            for (name_key, dest_obj) in dict {
                let name = name_key.as_str().to_string();
                // Only resolve if indirect; keep direct arrays as-is so page refs stay
                let to_parse = if dest_obj.as_obj_ref().is_some() {
                    resolve(dest_obj)
                } else {
                    dest_obj.clone()
                };
                if let Ok(dest) = parse_destination(&to_parse) {
                    nd.insert(name, dest);
                }
            }
        }
    }

    // /Names: name tree with /Dests
    if let Some(names_obj) = catalog.get(&Name::new("Names")) {
        let names = resolve(names_obj);
        if let Some(dict) = names.as_dict() {
            if let Some(dests_obj) = dict.get(&Name::new("Dests")) {
                let dests_tree = resolve(dests_obj);
                collect_from_name_tree(&dests_tree, &mut nd, &resolve);
            }
        }
    }

    Ok(nd)
}

fn collect_from_name_tree(
    obj: &Object,
    nd: &mut NamedDestinations,
    resolve: &impl Fn(&Object) -> Object,
) {
    match obj {
        Object::Dict(d) => {
            if let Some(kids) = d.get(&Name::new("Kids")).and_then(|o| o.as_array()) {
                for kid in kids {
                    let resolved = resolve(kid);
                    collect_from_name_tree(&resolved, nd, resolve);
                }
            }
            if let Some(names_arr) = d.get(&Name::new("Names")).and_then(|o| o.as_array()) {
                let mut i = 0;
                while i + 1 < names_arr.len() {
                    let name_obj = &names_arr[i];
                    let dest_obj = &names_arr[i + 1];
                    let name = get_string(&resolve(name_obj))
                        .or_else(|| get_name_str(name_obj).map(|s| s.to_string()));
                    if let Some(name) = name {
                        let resolved = resolve(dest_obj);
                        if let Ok(dest) = parse_destination(&resolved) {
                            nd.insert(name, dest);
                        }
                    }
                    i += 2;
                }
            }
        }
        _ => {}
    }
}

// ============================================================================
// resolve_destination
// ============================================================================

/// Resolve a named destination to a Destination.
pub fn resolve_destination(doc: &PdfDocument, name: &str) -> Option<Destination> {
    let catalog = doc.catalog().ok()?;
    let nd = parse_named_destinations(catalog, |o| doc.resolve(o)).ok()?;
    nd.get(name).cloned()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pdf::object::{ObjRef, PdfString};

    fn no_resolve(o: &Object) -> Object {
        o.clone()
    }

    #[test]
    fn test_parse_destination_fit() {
        let arr = vec![
            Object::Ref(ObjRef::new(5, 0)),
            Object::Name(Name::new("Fit")),
        ];
        let dest = parse_destination(&Object::Array(arr)).unwrap();
        assert!(matches!(dest.page, DestinationPage::Ref(r) if r.num == 5));
        assert!(matches!(dest.zoom, ZoomType::Fit));
    }

    #[test]
    fn test_parse_destination_xyz() {
        let arr = vec![
            Object::Ref(ObjRef::new(3, 0)),
            Object::Name(Name::new("XYZ")),
            Object::Real(100.0),
            Object::Real(200.0),
            Object::Real(1.5),
        ];
        let dest = parse_destination(&Object::Array(arr)).unwrap();
        assert!(matches!(
            dest.zoom,
            ZoomType::Xyz {
                left: Some(100.0),
                top: Some(200.0),
                zoom: Some(1.5)
            }
        ));
    }

    #[test]
    fn test_parse_destination_fith() {
        let arr = vec![
            Object::Ref(ObjRef::new(1, 0)),
            Object::Name(Name::new("FitH")),
            Object::Real(500.0),
        ];
        let dest = parse_destination(&Object::Array(arr)).unwrap();
        assert!(matches!(dest.zoom, ZoomType::FitH { top: Some(500.0) }));
    }

    #[test]
    fn test_parse_destination_fitr() {
        let arr = vec![
            Object::Ref(ObjRef::new(2, 0)),
            Object::Name(Name::new("FitR")),
            Object::Real(0.0),
            Object::Real(0.0),
            Object::Real(612.0),
            Object::Real(792.0),
        ];
        let dest = parse_destination(&Object::Array(arr)).unwrap();
        assert!(matches!(
            dest.zoom,
            ZoomType::FitR {
                left: 0.0,
                bottom: 0.0,
                right: 612.0,
                top: 792.0
            }
        ));
    }

    #[test]
    fn test_parse_destination_too_short() {
        let arr = vec![Object::Ref(ObjRef::new(5, 0))];
        assert!(parse_destination(&Object::Array(arr)).is_err());
    }

    #[test]
    fn test_parse_action_goto() {
        let dest_arr = Object::Array(vec![
            Object::Ref(ObjRef::new(4, 0)),
            Object::Name(Name::new("Fit")),
        ]);
        let mut dict = Dict::new();
        dict.insert(Name::new("S"), Object::Name(Name::new("GoTo")));
        dict.insert(Name::new("D"), dest_arr);

        let action = parse_action(&dict, no_resolve).unwrap();
        assert!(matches!(action, Action::GoTo { dest } if matches!(dest.zoom, ZoomType::Fit)));
    }

    #[test]
    fn test_parse_action_uri() {
        let mut dict = Dict::new();
        dict.insert(Name::new("S"), Object::Name(Name::new("URI")));
        dict.insert(
            Name::new("URI"),
            Object::String(PdfString::new(b"https://example.com".to_vec())),
        );
        dict.insert(Name::new("IsMap"), Object::Bool(true));

        let action = parse_action(&dict, no_resolve).unwrap();
        assert!(
            matches!(action, Action::URI { uri, is_map: true } if uri == "https://example.com")
        );
    }

    #[test]
    fn test_parse_action_javascript() {
        let mut dict = Dict::new();
        dict.insert(Name::new("S"), Object::Name(Name::new("JavaScript")));
        dict.insert(
            Name::new("JS"),
            Object::String(PdfString::new(b"app.alert('hi');".to_vec())),
        );

        let action = parse_action(&dict, no_resolve).unwrap();
        assert!(matches!(action, Action::JavaScript { script } if script == "app.alert('hi');"));
    }

    #[test]
    fn test_parse_action_named() {
        let mut dict = Dict::new();
        dict.insert(Name::new("S"), Object::Name(Name::new("Named")));
        dict.insert(Name::new("N"), Object::Name(Name::new("NextPage")));

        let action = parse_action(&dict, no_resolve).unwrap();
        assert!(matches!(action, Action::Named { name } if name == "NextPage"));
    }

    #[test]
    fn test_trigger_from_name() {
        assert_eq!(Trigger::from_name("E"), Some(Trigger::Enter));
        assert_eq!(Trigger::from_name("X"), Some(Trigger::Exit));
        assert_eq!(Trigger::from_name("D"), Some(Trigger::MouseDown));
        assert_eq!(Trigger::from_name("PO"), Some(Trigger::PageOpen));
        assert_eq!(Trigger::from_name("PC"), Some(Trigger::PageClose));
        assert_eq!(Trigger::from_name("Fo"), Some(Trigger::Focus));
        assert_eq!(Trigger::from_name("Bl"), Some(Trigger::Blur));
        assert_eq!(Trigger::from_name("WC"), Some(Trigger::DocClose));
        assert_eq!(Trigger::from_name("WP"), Some(Trigger::DocPrint));
        assert_eq!(Trigger::from_name("unknown"), None);
    }

    #[test]
    fn test_trigger_as_str() {
        assert_eq!(Trigger::Enter.as_str(), "E");
        assert_eq!(Trigger::PageOpen.as_str(), "PO");
        assert_eq!(Trigger::DocSave.as_str(), "WS");
    }

    #[test]
    fn test_parse_additional_actions() {
        let mut aa_dict = Dict::new();
        aa_dict.insert(
            Name::new("E"),
            Object::Dict({
                let mut a = Dict::new();
                a.insert(Name::new("S"), Object::Name(Name::new("URI")));
                a.insert(
                    Name::new("URI"),
                    Object::String(PdfString::new(b"https://test.com".to_vec())),
                );
                a
            }),
        );

        let mut dict = Dict::new();
        dict.insert(Name::new("AA"), Object::Dict(aa_dict));

        let actions = parse_additional_actions(&dict, no_resolve).unwrap();
        assert_eq!(actions.actions.len(), 1);
        assert!(actions.actions.get(&Trigger::Enter).is_some());
        assert!(matches!(
            actions.actions.get(&Trigger::Enter).unwrap(),
            Action::URI { uri, .. } if uri == "https://test.com"
        ));
    }

    #[test]
    fn test_parse_additional_actions_empty_without_aa() {
        let dict = Dict::new();
        let actions = parse_additional_actions(&dict, no_resolve).unwrap();
        assert!(actions.actions.is_empty());
    }

    #[test]
    fn test_parse_named_destinations() {
        let dest_arr = Object::Array(vec![
            Object::Ref(ObjRef::new(10, 0)),
            Object::Name(Name::new("XYZ")),
            Object::Real(0.0),
            Object::Real(800.0),
            Object::Null,
        ]);
        let mut dests_dict = Dict::new();
        dests_dict.insert(Name::new("chapter1"), dest_arr);

        let mut catalog = Dict::new();
        catalog.insert(Name::new("Type"), Object::Name(Name::new("Catalog")));
        catalog.insert(Name::new("Dests"), Object::Dict(dests_dict));

        let nd = parse_named_destinations(&catalog, no_resolve).unwrap();
        let dest = nd.get("chapter1").unwrap();
        assert!(matches!(dest.page, DestinationPage::Ref(r) if r.num == 10));
        assert!(matches!(
            dest.zoom,
            ZoomType::Xyz {
                left: Some(0.0),
                top: Some(800.0),
                zoom: None
            }
        ));
    }

    #[test]
    fn test_resolve_destination() {
        let dest_arr = Object::Array(vec![
            Object::Ref(ObjRef::new(3, 0)),
            Object::Name(Name::new("Fit")),
        ]);
        let mut dests_dict = Dict::new();
        dests_dict.insert(Name::new("intro"), dest_arr);

        let mut catalog = Dict::new();
        catalog.insert(Name::new("Type"), Object::Name(Name::new("Catalog")));
        catalog.insert(Name::new("Dests"), Object::Dict(dests_dict));

        let mut doc = PdfDocument::new();
        doc.objects.insert((1, 0), Object::Dict(catalog));
        doc.trailer
            .insert(Name::new("Root"), Object::Ref(ObjRef::new(1, 0)));

        let dest = resolve_destination(&doc, "intro").unwrap();
        assert!(matches!(dest.zoom, ZoomType::Fit));
    }

    #[test]
    fn test_resolve_destination_not_found() {
        let mut catalog = Dict::new();
        catalog.insert(Name::new("Type"), Object::Name(Name::new("Catalog")));

        let mut doc = PdfDocument::new();
        doc.objects.insert((1, 0), Object::Dict(catalog));
        doc.trailer
            .insert(Name::new("Root"), Object::Ref(ObjRef::new(1, 0)));

        assert!(resolve_destination(&doc, "nonexistent").is_none());
    }
}
