//! Page Operations — rotate, scale, shift, flip, crop, and box manipulation.
//!
//! Each operation extracts pages as single-page PDFs via `copy_page_to_bytes`,
//! applies a byte-level transform, and recombines with `combine_page_pdfs_bytes`.
//! This reuses the existing enhanced layer infrastructure.

use crate::cpdf::document::CpdfDocument;
use crate::cpdf::error::{CpdfError, Result};
use crate::enhanced::page_copy::copy_page_to_bytes;
use crate::enhanced::page_merge::combine_page_pdfs_bytes;
use std::collections::HashMap;

// ── Entries that copy_page_to_bytes drops but we must preserve ──────

const PRESERVED_KEYS: &[&str] = &["/Rotate", "/CropBox", "/TrimBox", "/BleedBox", "/ArtBox"];

// ── Core: apply transform to pages in range ────────────────────────

fn apply_to_pages(
    doc: CpdfDocument,
    range: &[usize],
    transform: impl Fn(&[u8]) -> Result<Vec<u8>>,
) -> Result<CpdfDocument> {
    let n = doc.page_count();
    if n == 0 {
        return Err(CpdfError::InvalidArgument("document has no pages".into()));
    }
    let page_obj_nums = get_page_obj_nums(&doc.data);
    let mut page_pdfs = Vec::with_capacity(n);
    for page_idx in 0..n {
        let mut page_pdf = copy_page_to_bytes(&doc.data, page_idx)
            .map_err(|e| CpdfError::Parse(format!("extract page {}: {e}", page_idx + 1)))?;

        let extras = extract_extras_for_page(&doc.data, &page_obj_nums, page_idx);
        if !extras.is_empty() {
            page_pdf = modify_page_dict(&page_pdf, |dict| {
                let mut d = dict.to_string();
                for (key, val) in &extras {
                    d = set_dict_entry(&d, key, val);
                }
                d
            })?;
        }

        if range.contains(&(page_idx + 1)) {
            page_pdfs.push(transform(&page_pdf)?);
        } else {
            page_pdfs.push(page_pdf);
        }
    }
    recombine(page_pdfs)
}

/// Extract a page as a single-page PDF, preserving extra page dict entries.
fn extract_page_preserving(
    doc_data: &[u8],
    page_obj_nums: &[i32],
    page_idx: usize,
) -> Result<Vec<u8>> {
    let mut page_pdf = copy_page_to_bytes(doc_data, page_idx)
        .map_err(|e| CpdfError::Parse(format!("extract page {}: {e}", page_idx + 1)))?;
    let extras = extract_extras_for_page(doc_data, page_obj_nums, page_idx);
    if !extras.is_empty() {
        page_pdf = modify_page_dict(&page_pdf, |dict| {
            let mut d = dict.to_string();
            for (key, val) in &extras {
                d = set_dict_entry(&d, key, val);
            }
            d
        })?;
    }
    Ok(page_pdf)
}

// ── Byte pattern helpers ────────────────────────────────────────────

fn find_pattern(data: &[u8], pattern: &[u8]) -> Option<usize> {
    data.windows(pattern.len()).position(|w| w == pattern)
}

fn find_pattern_from(data: &[u8], start: usize, pattern: &[u8]) -> Option<usize> {
    if start >= data.len() {
        return None;
    }
    find_pattern(&data[start..], pattern).map(|p| p + start)
}

fn find_last_pattern(data: &[u8], pattern: &[u8]) -> Option<usize> {
    if pattern.is_empty() || pattern.len() > data.len() {
        return None;
    }
    (0..=data.len() - pattern.len())
        .rev()
        .find(|&i| data[i..i + pattern.len()] == *pattern)
}

/// Find `N 0 obj` with word-boundary checking.
fn find_obj_start(data: &[u8], obj_num: i32) -> Option<usize> {
    let pat = format!("{} 0 obj", obj_num);
    let pat_bytes = pat.as_bytes();
    let mut pos = 0;
    while pos + pat_bytes.len() <= data.len() {
        if let Some(found) = find_pattern(&data[pos..], pat_bytes) {
            let abs = pos + found;
            let before_ok = abs == 0 || !data[abs - 1].is_ascii_digit();
            let end = abs + pat_bytes.len();
            let after_ok = end >= data.len() || !data[end].is_ascii_alphanumeric();
            if before_ok && after_ok {
                return Some(abs);
            }
            pos = abs + 1;
        } else {
            break;
        }
    }
    None
}

/// Find matching `>>` for `<<` at position `start`. Returns position of first `>`.
fn find_matching_dict_end(data: &[u8], start: usize) -> Option<usize> {
    let mut depth = 0i32;
    let mut i = start;
    while i + 1 < data.len() {
        if data[i] == b'<' && data[i + 1] == b'<' {
            depth += 1;
            i += 2;
        } else if data[i] == b'>' && data[i + 1] == b'>' {
            depth -= 1;
            if depth == 0 {
                return Some(i);
            }
            i += 2;
        } else {
            i += 1;
        }
    }
    None
}

/// Scan for all `N 0 obj` positions and return { obj_num → byte_offset }.
fn find_all_obj_offsets(data: &[u8]) -> HashMap<i32, usize> {
    let mut map = HashMap::new();
    let mut pos = 0;
    while pos < data.len() {
        let at_line_start = pos == 0 || data[pos - 1] == b'\n' || data[pos - 1] == b'\r';
        if at_line_start && data[pos].is_ascii_digit() {
            let num_start = pos;
            let mut num_end = pos;
            while num_end < data.len() && data[num_end].is_ascii_digit() {
                num_end += 1;
            }
            if num_end > num_start
                && num_end + 5 < data.len()
                && data[num_end..].starts_with(b" 0 obj")
            {
                let after = num_end + 6;
                if after >= data.len() || !data[after].is_ascii_alphanumeric() {
                    if let Ok(n) = std::str::from_utf8(&data[num_start..num_end])
                        .unwrap_or("")
                        .parse::<i32>()
                    {
                        map.insert(n, num_start);
                    }
                }
                pos = num_end + 6;
                continue;
            }
        }
        pos += 1;
    }
    map
}

/// Truncate everything from the xref section onwards and write a fresh xref + trailer.
fn fix_xref(pdf: &mut Vec<u8>) -> Result<()> {
    let xref_pos = find_last_pattern(pdf, b"\nxref\n")
        .map(|p| p + 1)
        .or_else(|| find_last_pattern(pdf, b"\nxref\r\n").map(|p| p + 1))
        .or_else(|| {
            if pdf.starts_with(b"xref\n") {
                Some(0)
            } else {
                None
            }
        })
        .ok_or_else(|| CpdfError::Parse("xref section not found".into()))?;

    pdf.truncate(xref_pos);

    let offsets = find_all_obj_offsets(pdf);
    let max_obj = offsets.keys().max().copied().unwrap_or(0);

    let new_xref_pos = pdf.len();
    pdf.extend_from_slice(format!("xref\n0 {}\n", max_obj + 1).as_bytes());
    pdf.extend_from_slice(b"0000000000 65535 f \n");
    for i in 1..=max_obj {
        let off = offsets.get(&i).copied().unwrap_or(0);
        pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
    }
    pdf.extend_from_slice(
        format!(
            "trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{new_xref_pos}\n%%EOF\n",
            max_obj + 1
        )
        .as_bytes(),
    );
    Ok(())
}

// ── Page-dict modification ──────────────────────────────────────────

/// Modify the page dictionary of a single-page PDF (page is always obj 3).
fn modify_page_dict(pdf: &[u8], modify: impl FnOnce(&str) -> String) -> Result<Vec<u8>> {
    let obj_pos =
        find_obj_start(pdf, 3).ok_or_else(|| CpdfError::Parse("page object 3 not found".into()))?;

    let dict_start = find_pattern_from(pdf, obj_pos, b"<<")
        .ok_or_else(|| CpdfError::Parse("page dict << not found".into()))?;
    let dict_end_gt = find_matching_dict_end(pdf, dict_start)
        .ok_or_else(|| CpdfError::Parse("page dict >> not found".into()))?;
    let dict_end = dict_end_gt + 2;

    let old_dict = std::str::from_utf8(&pdf[dict_start..dict_end])
        .map_err(|e| CpdfError::Parse(format!("page dict not UTF-8: {e}")))?;
    let new_dict = modify(old_dict);

    let line_start = line_start_of(pdf, obj_pos);

    let endobj = find_pattern_from(pdf, dict_end, b"endobj")
        .ok_or_else(|| CpdfError::Parse("endobj not found".into()))?;
    let mut obj_end = endobj + 6;
    while obj_end < pdf.len() && (pdf[obj_end] == b'\n' || pdf[obj_end] == b'\r') {
        obj_end += 1;
    }

    let mut out = Vec::with_capacity(pdf.len());
    out.extend_from_slice(&pdf[..line_start]);
    out.extend_from_slice(format!("3 0 obj\n{new_dict}\nendobj\n").as_bytes());
    out.extend_from_slice(&pdf[obj_end..]);

    fix_xref(&mut out)?;
    Ok(out)
}

fn line_start_of(data: &[u8], pos: usize) -> usize {
    let mut s = pos;
    while s > 0 && data[s - 1] != b'\n' && data[s - 1] != b'\r' {
        s -= 1;
    }
    s
}

/// Extract the page dict string (including `<<…>>`) from a single-page PDF.
fn extract_page_dict_str(pdf: &[u8]) -> Result<String> {
    let obj_pos =
        find_obj_start(pdf, 3).ok_or_else(|| CpdfError::Parse("page object 3 not found".into()))?;
    let dict_start = find_pattern_from(pdf, obj_pos, b"<<")
        .ok_or_else(|| CpdfError::Parse("page dict << not found".into()))?;
    let dict_end = find_matching_dict_end(pdf, dict_start)
        .ok_or_else(|| CpdfError::Parse("page dict >> not found".into()))?;
    let s = std::str::from_utf8(&pdf[dict_start..dict_end + 2])
        .map_err(|e| CpdfError::Parse(format!("dict not UTF-8: {e}")))?;
    Ok(s.to_string())
}

// ── Dict-entry helpers ──────────────────────────────────────────────

/// Set or replace a key. `value` includes leading space if needed.
fn set_dict_entry(dict: &str, key: &str, value: &str) -> String {
    let without = remove_dict_entry(dict, key);
    let insert = without.rfind(">>").unwrap_or(without.len());
    format!("{}{key}{value}{}", &without[..insert], &without[insert..])
}

/// Remove a key and its value from a dict string.
fn remove_dict_entry(dict: &str, key: &str) -> String {
    if let Some(key_pos) = dict.find(key) {
        let before = &dict[..key_pos];
        let after_key = &dict[key_pos + key.len()..];
        let val_end = find_next_entry_or_close(after_key);
        format!("{before}{}", &after_key[val_end..])
    } else {
        dict.to_string()
    }
}

/// Get a dict value as string (trimmed, without the key name).
fn get_dict_value(dict: &str, key: &str) -> Option<String> {
    let key_pos = dict.find(key)?;
    let after_key = &dict[key_pos + key.len()..];
    let val_end = find_next_entry_or_close(after_key);
    Some(after_key[..val_end].trim().to_string())
}

/// Get an integer value from a dict entry.
fn get_dict_int_value(dict: &str, key: &str) -> Option<i32> {
    get_dict_value(dict, key)?.trim().parse::<i32>().ok()
}

/// Find where the next dict entry (`/`) or closing `>>` starts, respecting nesting.
fn find_next_entry_or_close(s: &str) -> usize {
    let bytes = s.as_bytes();
    let mut i = 0usize;
    let mut depth = 0i32;
    while i < bytes.len() {
        match bytes[i] {
            b'[' => {
                depth += 1;
                i += 1;
            }
            b']' => {
                depth -= 1;
                i += 1;
            }
            b'<' if i + 1 < bytes.len() && bytes[i + 1] == b'<' => {
                depth += 1;
                i += 2;
            }
            b'>' if i + 1 < bytes.len() && bytes[i + 1] == b'>' => {
                if depth == 0 {
                    return i;
                }
                depth -= 1;
                i += 2;
            }
            b'(' => {
                i += 1;
                while i < bytes.len() {
                    if bytes[i] == b'\\' {
                        i += 2;
                        continue;
                    }
                    if bytes[i] == b')' {
                        i += 1;
                        break;
                    }
                    i += 1;
                }
            }
            b'/' if depth == 0 => return i,
            _ => i += 1,
        }
    }
    s.len()
}

// ── Original-doc page extras ────────────────────────────────────────

/// Get ordered page object numbers from the /Kids array of the /Pages object.
fn get_page_obj_nums(doc_data: &[u8]) -> Vec<i32> {
    let patterns: &[&[u8]] = &[b"/Type/Pages", b"/Type /Pages"];
    for pat in patterns {
        let mut search_pos = 0;
        while search_pos < doc_data.len() {
            let Some(offset) = find_pattern(&doc_data[search_pos..], pat) else {
                break;
            };
            let pages_pos = search_pos + offset;
            let end = (pages_pos + 1000).min(doc_data.len());
            if let Some(kids_off) = find_pattern(&doc_data[pages_pos..end], b"/Kids") {
                let after = &doc_data[pages_pos + kids_off + 5..end];
                let trimmed_start = after
                    .iter()
                    .position(|b| !b.is_ascii_whitespace())
                    .unwrap_or(0);
                let trimmed = &after[trimmed_start..];
                if trimmed.starts_with(b"[") {
                    let arr_end = trimmed
                        .iter()
                        .position(|&b| b == b']')
                        .unwrap_or(trimmed.len());
                    let arr = String::from_utf8_lossy(&trimmed[1..arr_end]);
                    let parts: Vec<&str> = arr.split_whitespace().collect();
                    let mut refs = Vec::new();
                    let mut i = 0;
                    while i + 2 < parts.len() {
                        if parts[i + 2].starts_with('R') {
                            if let Ok(n) = parts[i].parse::<i32>() {
                                refs.push(n);
                            }
                            i += 3;
                        } else {
                            i += 1;
                        }
                    }
                    if !refs.is_empty() {
                        return refs;
                    }
                }
            }
            search_pos = pages_pos + pat.len();
        }
    }
    Vec::new()
}

/// Get the dict string of an object by number.
fn get_obj_dict_str(doc_data: &[u8], obj_num: i32) -> Option<String> {
    let start = find_obj_start(doc_data, obj_num)?;
    let dict_start = find_pattern_from(doc_data, start, b"<<")?;
    let dict_end = find_matching_dict_end(doc_data, dict_start)?;
    std::str::from_utf8(&doc_data[dict_start..dict_end + 2])
        .ok()
        .map(|s| s.to_string())
}

/// Extract the extra page dict entries that `copy_page_to_bytes` drops.
fn extract_extras_for_page(
    doc_data: &[u8],
    page_obj_nums: &[i32],
    page_idx: usize,
) -> Vec<(String, String)> {
    if page_idx >= page_obj_nums.len() {
        return Vec::new();
    }
    let obj_num = page_obj_nums[page_idx];
    let Some(dict) = get_obj_dict_str(doc_data, obj_num) else {
        return Vec::new();
    };
    let mut extras = Vec::new();
    for &key in PRESERVED_KEYS {
        if let Some(val) = get_dict_value(&dict, key) {
            extras.push((key.to_string(), val));
        }
    }
    extras
}

// ── MediaBox helpers ────────────────────────────────────────────────

/// Parse `/MediaBox` from a single-page PDF's page dict.
pub(crate) fn get_page_mediabox(pdf: &[u8]) -> Result<[f64; 4]> {
    let dict = extract_page_dict_str(pdf)?;
    parse_box_from_dict(&dict, "/MediaBox")
        .ok_or_else(|| CpdfError::Parse("no /MediaBox in page dict".into()))
}

fn parse_box_from_dict(dict: &str, key: &str) -> Option<[f64; 4]> {
    let val = get_dict_value(dict, key)?;
    parse_box_array(&val)
}

fn parse_box_array(s: &str) -> Option<[f64; 4]> {
    let inner = s
        .trim()
        .strip_prefix('[')
        .and_then(|s| s.strip_suffix(']'))?;
    let nums: Vec<f64> = inner
        .split_whitespace()
        .filter_map(|t| t.parse().ok())
        .collect();
    if nums.len() >= 4 {
        Some([nums[0], nums[1], nums[2], nums[3]])
    } else {
        None
    }
}

// ── Content-stream wrapping ─────────────────────────────────────────

/// Wrap the content stream of a single-page PDF with `prefix` … `suffix`.
/// Decompresses FlateDecode, writes back uncompressed.
fn wrap_content_stream(pdf: &[u8], prefix: &str, suffix: &str) -> Result<Vec<u8>> {
    let dict_str = extract_page_dict_str(pdf)?;
    let content_refs = parse_contents_reference(&dict_str)?;

    if content_refs.is_empty() {
        return Ok(pdf.to_vec());
    }

    let mut combined = Vec::new();
    for &obj in &content_refs {
        let data = extract_and_decompress_stream(pdf, obj)?;
        if !combined.is_empty() {
            combined.push(b'\n');
        }
        combined.extend_from_slice(&data);
    }

    let mut wrapped = Vec::new();
    wrapped.extend_from_slice(prefix.as_bytes());
    wrapped.extend_from_slice(&combined);
    wrapped.extend_from_slice(suffix.as_bytes());

    let first = content_refs[0];
    let mut out = replace_stream_object(pdf, first, &wrapped)?;

    if content_refs.len() > 1 {
        out = modify_page_dict(&out, |d| {
            set_dict_entry(d, "/Contents", &format!(" {first} 0 R"))
        })?;
    }

    Ok(out)
}

/// Replace the stream data of object `obj_num` with `new_data` (uncompressed).
fn replace_stream_object(pdf: &[u8], obj_num: i32, new_data: &[u8]) -> Result<Vec<u8>> {
    let obj_pos = find_obj_start(pdf, obj_num)
        .ok_or_else(|| CpdfError::Parse(format!("stream object {obj_num} not found")))?;

    let line_start = line_start_of(pdf, obj_pos);

    let stream_kw = find_pattern_from(pdf, obj_pos, b"stream")
        .ok_or_else(|| CpdfError::Parse("stream keyword not found".into()))?;
    let endstream = find_pattern_from(pdf, stream_kw + 6, b"endstream")
        .ok_or_else(|| CpdfError::Parse("endstream not found".into()))?;
    let endobj = find_pattern_from(pdf, endstream, b"endobj")
        .ok_or_else(|| CpdfError::Parse("endobj not found".into()))?;
    let mut obj_end = endobj + 6;
    while obj_end < pdf.len() && (pdf[obj_end] == b'\n' || pdf[obj_end] == b'\r') {
        obj_end += 1;
    }

    let new_obj = format!("{obj_num} 0 obj\n<</Length {}>>\nstream\n", new_data.len());
    let mut new_obj_bytes = new_obj.into_bytes();
    new_obj_bytes.extend_from_slice(new_data);
    new_obj_bytes.extend_from_slice(b"\nendstream\nendobj\n");

    let mut out = Vec::with_capacity(pdf.len());
    out.extend_from_slice(&pdf[..line_start]);
    out.extend_from_slice(&new_obj_bytes);
    out.extend_from_slice(&pdf[obj_end..]);

    fix_xref(&mut out)?;
    Ok(out)
}

/// Extract and decompress the stream data of an object.
fn extract_and_decompress_stream(pdf: &[u8], obj_num: i32) -> Result<Vec<u8>> {
    let obj_pos = find_obj_start(pdf, obj_num)
        .ok_or_else(|| CpdfError::Parse(format!("stream object {obj_num} not found")))?;

    let dict_start = find_pattern_from(pdf, obj_pos, b"<<")
        .ok_or_else(|| CpdfError::Parse("stream dict not found".into()))?;
    let dict_end = find_matching_dict_end(pdf, dict_start)
        .ok_or_else(|| CpdfError::Parse("stream dict end not found".into()))?;
    let dict_bytes = &pdf[dict_start..dict_end + 2];
    let is_compressed = find_pattern(dict_bytes, b"/FlateDecode").is_some()
        || find_pattern(dict_bytes, b"/Fl ").is_some();

    let stream_kw = find_pattern_from(pdf, dict_end, b"stream")
        .ok_or_else(|| CpdfError::Parse("stream keyword not found".into()))?;
    let mut data_start = stream_kw + 6;
    if data_start < pdf.len() && pdf[data_start] == b'\r' {
        data_start += 1;
    }
    if data_start < pdf.len() && pdf[data_start] == b'\n' {
        data_start += 1;
    }

    let endstream = find_pattern_from(pdf, data_start, b"endstream")
        .ok_or_else(|| CpdfError::Parse("endstream not found".into()))?;
    let mut data_end = endstream;
    while data_end > data_start && (pdf[data_end - 1] == b'\n' || pdf[data_end - 1] == b'\r') {
        data_end -= 1;
    }

    let raw = &pdf[data_start..data_end];

    if is_compressed && !raw.is_empty() {
        decompress_flate(raw)
    } else {
        Ok(raw.to_vec())
    }
}

fn decompress_flate(data: &[u8]) -> Result<Vec<u8>> {
    use flate2::read::ZlibDecoder;
    use std::io::Read;
    let mut decoder = ZlibDecoder::new(data);
    let mut out = Vec::new();
    match decoder.read_to_end(&mut out) {
        Ok(_) => Ok(out),
        Err(_) => Ok(data.to_vec()),
    }
}

fn parse_contents_reference(dict: &str) -> Result<Vec<i32>> {
    let mut result = Vec::new();
    let Some(pos) = dict.find("/Contents") else {
        return Ok(result);
    };
    let after = dict[pos + 9..].trim_start();
    if after.starts_with('[') {
        let end = after.find(']').unwrap_or(after.len());
        let array = &after[1..end];
        let parts: Vec<&str> = array.split_whitespace().collect();
        let mut i = 0;
        while i + 2 < parts.len() {
            if parts[i + 2].starts_with('R') {
                if let Ok(n) = parts[i].parse::<i32>() {
                    result.push(n);
                }
                i += 3;
            } else {
                i += 1;
            }
        }
    } else {
        let parts: Vec<&str> = after.split_whitespace().take(3).collect();
        if parts.len() >= 3 && parts[2].starts_with('R') {
            if let Ok(n) = parts[0].parse::<i32>() {
                result.push(n);
            }
        }
    }
    Ok(result)
}

// ════════════════════════════════════════════════════════════════════
// Public API
// ════════════════════════════════════════════════════════════════════

// ── Rotation ────────────────────────────────────────────────────────

/// Set the `/Rotate` entry to an absolute value (must be 0, 90, 180, or 270).
pub fn rotate_pages(doc: CpdfDocument, range: &[usize], degrees: i32) -> Result<CpdfDocument> {
    if !matches!(degrees, 0 | 90 | 180 | 270) {
        return Err(CpdfError::InvalidArgument(format!(
            "rotation must be 0, 90, 180, or 270; got {degrees}"
        )));
    }
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |dict| {
            if degrees == 0 {
                remove_dict_entry(dict, "/Rotate")
            } else {
                set_dict_entry(dict, "/Rotate", &format!(" {degrees}"))
            }
        })
    })
}

/// Add `degrees` to the current `/Rotate` value (cumulative, normalised mod 360).
pub fn rotate_pages_by(doc: CpdfDocument, range: &[usize], degrees: i32) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |dict| {
            let current = get_dict_int_value(dict, "/Rotate").unwrap_or(0);
            let new_rot = ((current + degrees) % 360 + 360) % 360;
            if new_rot == 0 {
                remove_dict_entry(dict, "/Rotate")
            } else {
                set_dict_entry(dict, "/Rotate", &format!(" {new_rot}"))
            }
        })
    })
}

/// Rotate the content stream by `angle_deg` (arbitrary angle).
pub fn rotate_contents(doc: CpdfDocument, range: &[usize], angle_deg: f64) -> Result<CpdfDocument> {
    let rad = angle_deg * std::f64::consts::PI / 180.0;
    let c = rad.cos();
    let s = rad.sin();
    let prefix = format!("q {c:.6} {s:.6} {:.6} {c:.6} 0 0 cm\n", -s);
    apply_to_pages(doc, range, |pdf| wrap_content_stream(pdf, &prefix, "\nQ"))
}

/// Remove `/Rotate` from all pages in range, normalising orientation to 0.
pub fn upright(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |dict| remove_dict_entry(dict, "/Rotate"))
    })
}

// ── Scaling / shifting / flipping ───────────────────────────────────

/// Scale page contents by `(sx, sy)` via content-stream matrix.
pub fn scale_pages(doc: CpdfDocument, range: &[usize], sx: f64, sy: f64) -> Result<CpdfDocument> {
    let prefix = format!("q {sx:.6} 0 0 {sy:.6} 0 0 cm\n");
    apply_to_pages(doc, range, |pdf| wrap_content_stream(pdf, &prefix, "\nQ"))
}

/// Scale page contents to fit within `(target_w, target_h)`, preserving aspect ratio.
pub fn scale_to_fit(
    doc: CpdfDocument,
    range: &[usize],
    target_w: f64,
    target_h: f64,
    scale_factor: f64,
) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        let mbox = get_page_mediabox(pdf)?;
        let pw = mbox[2] - mbox[0];
        let ph = mbox[3] - mbox[1];
        if pw <= 0.0 || ph <= 0.0 {
            return Err(CpdfError::InvalidArgument(
                "page has zero dimensions".into(),
            ));
        }
        let s = (target_w / pw).min(target_h / ph) * scale_factor;
        let dx = (target_w - pw * s) / 2.0;
        let dy = (target_h - ph * s) / 2.0;
        let prefix = format!("q {s:.6} 0 0 {s:.6} {dx:.6} {dy:.6} cm\n");
        let pdf = wrap_content_stream(pdf, &prefix, "\nQ")?;
        modify_page_dict(&pdf, |d| {
            set_dict_entry(d, "/MediaBox", &format!("[0 0 {target_w} {target_h}]"))
        })
    })
}

/// Non-uniformly stretch page contents to exactly `(width, height)`.
pub fn stretch_pages(
    doc: CpdfDocument,
    range: &[usize],
    width: f64,
    height: f64,
) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        let mbox = get_page_mediabox(pdf)?;
        let pw = mbox[2] - mbox[0];
        let ph = mbox[3] - mbox[1];
        if pw <= 0.0 || ph <= 0.0 {
            return Err(CpdfError::InvalidArgument(
                "page has zero dimensions".into(),
            ));
        }
        let sx = width / pw;
        let sy = height / ph;
        let prefix = format!("q {sx:.6} 0 0 {sy:.6} 0 0 cm\n");
        let pdf = wrap_content_stream(pdf, &prefix, "\nQ")?;
        modify_page_dict(&pdf, |d| {
            set_dict_entry(d, "/MediaBox", &format!("[0 0 {width} {height}]"))
        })
    })
}

/// Translate page contents by `(dx, dy)` points.
pub fn shift_pages(doc: CpdfDocument, range: &[usize], dx: f64, dy: f64) -> Result<CpdfDocument> {
    let prefix = format!("q 1 0 0 1 {dx:.6} {dy:.6} cm\n");
    apply_to_pages(doc, range, |pdf| wrap_content_stream(pdf, &prefix, "\nQ"))
}

/// Flip page contents vertically (about the horizontal midline).
pub fn vflip_pages(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        let mbox = get_page_mediabox(pdf)?;
        let h = mbox[3] - mbox[1];
        let prefix = format!("q 1 0 0 -1 0 {h:.6} cm\n");
        wrap_content_stream(pdf, &prefix, "\nQ")
    })
}

/// Flip page contents horizontally (about the vertical midline).
pub fn hflip_pages(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, |pdf| {
        let mbox = get_page_mediabox(pdf)?;
        let w = mbox[2] - mbox[0];
        let prefix = format!("q -1 0 0 1 {w:.6} 0 cm\n");
        wrap_content_stream(pdf, &prefix, "\nQ")
    })
}

// ── Box operations ──────────────────────────────────────────────────

fn set_box(
    doc: CpdfDocument,
    range: &[usize],
    box_name: &str,
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    let key = format!("/{box_name}");
    let value = format!("[{x} {y} {w} {h}]");
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |d| set_dict_entry(d, &key, &value))
    })
}

fn remove_box(doc: CpdfDocument, range: &[usize], box_name: &str) -> Result<CpdfDocument> {
    let key = format!("/{box_name}");
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |d| remove_dict_entry(d, &key))
    })
}

pub fn crop_pages(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "CropBox", x, y, w, h)
}

pub fn set_mediabox(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "MediaBox", x, y, w, h)
}

pub fn set_cropbox(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "CropBox", x, y, w, h)
}

pub fn set_trimbox(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "TrimBox", x, y, w, h)
}

pub fn set_bleedbox(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "BleedBox", x, y, w, h)
}

pub fn set_artbox(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
) -> Result<CpdfDocument> {
    set_box(doc, range, "ArtBox", x, y, w, h)
}

pub fn remove_cropbox(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    remove_box(doc, range, "CropBox")
}

pub fn remove_trimbox(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    remove_box(doc, range, "TrimBox")
}

pub fn remove_bleedbox(doc: CpdfDocument, range: &[usize]) -> Result<CpdfDocument> {
    remove_box(doc, range, "BleedBox")
}

/// Copy the value of `from_box` (e.g. "MediaBox") to `to_box` (e.g. "CropBox").
pub fn copy_box(
    doc: CpdfDocument,
    range: &[usize],
    from_box: &str,
    to_box: &str,
) -> Result<CpdfDocument> {
    let from_key = format!("/{from_box}");
    let to_key = format!("/{to_box}");
    apply_to_pages(doc, range, |pdf| {
        modify_page_dict(pdf, |d| {
            if let Some(val) = get_dict_value(d, &from_key) {
                set_dict_entry(d, &to_key, &val)
            } else {
                d.to_string()
            }
        })
    })
}

/// Clip page content to the named box using a clipping rectangle.
pub fn hard_box(doc: CpdfDocument, range: &[usize], box_name: &str) -> Result<CpdfDocument> {
    let key = format!("/{box_name}");
    apply_to_pages(doc, range, |pdf| {
        let dict = extract_page_dict_str(pdf)?;
        if let Some(coords) = parse_box_from_dict(&dict, &key) {
            let clip = format!(
                "q {} {} {} {} re W n\n",
                coords[0],
                coords[1],
                coords[2] - coords[0],
                coords[3] - coords[1],
            );
            wrap_content_stream(pdf, &clip, "\nQ")
        } else {
            Ok(pdf.to_vec())
        }
    })
}

// ── Chop ────────────────────────────────────────────────────────────

/// Chop each page in `range` into `x_pieces × y_pieces` sub-pages.
pub fn chop(
    doc: CpdfDocument,
    range: &[usize],
    x_pieces: usize,
    y_pieces: usize,
    columns: bool,
    btt: bool,
    rtl: bool,
) -> Result<CpdfDocument> {
    if x_pieces == 0 || y_pieces == 0 {
        return Err(CpdfError::InvalidArgument("pieces must be > 0".into()));
    }

    let n = doc.page_count();
    let page_obj_nums = get_page_obj_nums(&doc.data);
    let mut all = Vec::new();

    for page_idx in 0..n {
        let page_pdf = extract_page_preserving(&doc.data, &page_obj_nums, page_idx)?;

        if range.contains(&(page_idx + 1)) {
            let mbox = get_page_mediabox(&page_pdf)?;
            let piece_w = (mbox[2] - mbox[0]) / x_pieces as f64;
            let piece_h = (mbox[3] - mbox[1]) / y_pieces as f64;

            let ordered = chop_order(x_pieces, y_pieces, columns, btt, rtl);

            for (col, row) in ordered {
                let x0 = mbox[0] + col as f64 * piece_w;
                let y0 = mbox[1] + row as f64 * piece_h;
                let piece = modify_page_dict(&page_pdf, |d| {
                    set_dict_entry(
                        d,
                        "/MediaBox",
                        &format!("[{x0} {y0} {} {}]", x0 + piece_w, y0 + piece_h),
                    )
                })?;
                all.push(piece);
            }
        } else {
            all.push(page_pdf);
        }
    }

    recombine(all)
}

/// Chop a page horizontally or vertically at `position` (points from bottom / left).
pub fn chop_hv(
    doc: CpdfDocument,
    range: &[usize],
    is_horizontal: bool,
    position: f64,
) -> Result<CpdfDocument> {
    let n = doc.page_count();
    let page_obj_nums = get_page_obj_nums(&doc.data);
    let mut all = Vec::new();

    for page_idx in 0..n {
        let page_pdf = extract_page_preserving(&doc.data, &page_obj_nums, page_idx)?;

        if range.contains(&(page_idx + 1)) {
            let mbox = get_page_mediabox(&page_pdf)?;

            if is_horizontal {
                let split_y = mbox[1] + position;
                let bottom = modify_page_dict(&page_pdf, |d| {
                    set_dict_entry(
                        d,
                        "/MediaBox",
                        &format!("[{} {} {} {split_y}]", mbox[0], mbox[1], mbox[2]),
                    )
                })?;
                let top = modify_page_dict(&page_pdf, |d| {
                    set_dict_entry(
                        d,
                        "/MediaBox",
                        &format!("[{} {split_y} {} {}]", mbox[0], mbox[2], mbox[3]),
                    )
                })?;
                all.push(bottom);
                all.push(top);
            } else {
                let split_x = mbox[0] + position;
                let left = modify_page_dict(&page_pdf, |d| {
                    set_dict_entry(
                        d,
                        "/MediaBox",
                        &format!("[{} {} {split_x} {}]", mbox[0], mbox[1], mbox[3]),
                    )
                })?;
                let right = modify_page_dict(&page_pdf, |d| {
                    set_dict_entry(
                        d,
                        "/MediaBox",
                        &format!("[{split_x} {} {} {}]", mbox[1], mbox[2], mbox[3]),
                    )
                })?;
                all.push(left);
                all.push(right);
            }
        } else {
            all.push(page_pdf);
        }
    }

    recombine(all)
}

fn chop_order(
    x_pieces: usize,
    y_pieces: usize,
    columns: bool,
    btt: bool,
    rtl: bool,
) -> Vec<(usize, usize)> {
    let mut ordered = Vec::with_capacity(x_pieces * y_pieces);
    if columns {
        for col in 0..x_pieces {
            for row in 0..y_pieces {
                let c = if rtl { x_pieces - 1 - col } else { col };
                let r = if btt { row } else { y_pieces - 1 - row };
                ordered.push((c, r));
            }
        }
    } else {
        for row in 0..y_pieces {
            for col in 0..x_pieces {
                let c = if rtl { x_pieces - 1 - col } else { col };
                let r = if btt { row } else { y_pieces - 1 - row };
                ordered.push((c, r));
            }
        }
    }
    ordered
}

// ── Two-up ──────────────────────────────────────────────────────────

/// Place 2 logical pages side-by-side on each physical page (scales down).
pub fn twoup(doc: CpdfDocument) -> Result<CpdfDocument> {
    Err(CpdfError::Unsupported(
        "twoup requires Form XObject creation — not yet implemented".into(),
    ))
}

/// Place 2 logical pages side-by-side, rotating 90°, enlarging media box.
pub fn twoup_stack(doc: CpdfDocument) -> Result<CpdfDocument> {
    Err(CpdfError::Unsupported(
        "twoup_stack requires Form XObject creation — not yet implemented".into(),
    ))
}

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

fn recombine(page_pdfs: Vec<Vec<u8>>) -> Result<CpdfDocument> {
    if page_pdfs.is_empty() {
        return Err(CpdfError::InvalidArgument("no pages".into()));
    }
    if page_pdfs.len() == 1 {
        return Ok(CpdfDocument {
            data: page_pdfs.into_iter().next().unwrap(),
        });
    }
    let data = combine_page_pdfs_bytes(&page_pdfs)
        .map_err(|e| CpdfError::Parse(format!("recombine: {e}")))?;
    Ok(CpdfDocument { data })
}

// ── Crate-visible wrappers for cross-module use ─────────────────────

pub fn apply_to_pages_pub(
    doc: CpdfDocument,
    range: &[usize],
    transform: impl Fn(&[u8]) -> Result<Vec<u8>>,
) -> Result<CpdfDocument> {
    apply_to_pages(doc, range, transform)
}

pub fn get_page_obj_nums_pub(data: &[u8]) -> Vec<i32> {
    get_page_obj_nums(data)
}

pub fn extract_extras_for_page_pub(
    data: &[u8],
    page_obj_nums: &[i32],
    page_idx: usize,
) -> Vec<(String, String)> {
    extract_extras_for_page(data, page_obj_nums, page_idx)
}

pub fn modify_page_dict_pub(pdf: &[u8], modify: impl FnOnce(&str) -> String) -> Result<Vec<u8>> {
    modify_page_dict(pdf, modify)
}

pub fn set_dict_entry_pub(dict: &str, key: &str, value: &str) -> String {
    set_dict_entry(dict, key, value)
}
