//! Stamps, watermarks, text overlays, and rectangles.
//!
//! Operations inject PDF content-stream operators (BT/ET, re, cm, etc.)
//! into page streams, either on top (stamp-on) or underneath (stamp-under)
//! the existing content.
//!
//! Zero `unsafe` in this file.

use crate::cpdf::document::CpdfDocument;
use crate::cpdf::error::{CpdfError, Result};
use crate::cpdf::pages;

// ── Enums ───────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub enum Position {
    TopLeft(f64),
    Top(f64),
    TopRight(f64),
    Left(f64),
    Centre,
    Right(f64),
    BottomLeft(f64),
    Bottom(f64),
    BottomRight(f64),
    Diagonal,
    ReverseDiagonal,
    Absolute(f64, f64),
}

#[derive(Debug, Clone, Copy)]
pub enum Colour {
    Grey(f64),
    Rgb(f64, f64, f64),
    Cmyk(f64, f64, f64, f64),
}

impl Colour {
    pub fn to_fill_ops(&self) -> String {
        match self {
            Colour::Grey(g) => format!("{g} g"),
            Colour::Rgb(r, g, b) => format!("{r} {g} {b} rg"),
            Colour::Cmyk(c, m, y, k) => format!("{c} {m} {y} {k} k"),
        }
    }

    pub fn to_stroke_ops(&self) -> String {
        match self {
            Colour::Grey(g) => format!("{g} G"),
            Colour::Rgb(r, g, b) => format!("{r} {g} {b} RG"),
            Colour::Cmyk(c, m, y, k) => format!("{c} {m} {y} {k} K"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Justification {
    Left,
    Centre,
    Right,
}

#[derive(Debug, Clone)]
pub enum Font {
    Helvetica,
    HelveticaBold,
    HelveticaOblique,
    HelveticaBoldOblique,
    TimesRoman,
    TimesBold,
    TimesItalic,
    TimesBoldItalic,
    Courier,
    CourierBold,
    CourierOblique,
    CourierBoldOblique,
    Symbol,
    ZapfDingbats,
    Custom(String),
}

impl Font {
    pub fn pdf_name(&self) -> &str {
        match self {
            Font::Helvetica => "Helvetica",
            Font::HelveticaBold => "Helvetica-Bold",
            Font::HelveticaOblique => "Helvetica-Oblique",
            Font::HelveticaBoldOblique => "Helvetica-BoldOblique",
            Font::TimesRoman => "Times-Roman",
            Font::TimesBold => "Times-Bold",
            Font::TimesItalic => "Times-Italic",
            Font::TimesBoldItalic => "Times-BoldItalic",
            Font::Courier => "Courier",
            Font::CourierBold => "Courier-Bold",
            Font::CourierOblique => "Courier-Oblique",
            Font::CourierBoldOblique => "Courier-BoldOblique",
            Font::Symbol => "Symbol",
            Font::ZapfDingbats => "ZapfDingbats",
            Font::Custom(s) => s.as_str(),
        }
    }

    pub fn from_code(code: i32) -> Result<Self> {
        match code {
            0 => Ok(Font::Helvetica),
            1 => Ok(Font::HelveticaBold),
            2 => Ok(Font::HelveticaOblique),
            3 => Ok(Font::HelveticaBoldOblique),
            4 => Ok(Font::TimesRoman),
            5 => Ok(Font::TimesBold),
            6 => Ok(Font::TimesItalic),
            7 => Ok(Font::TimesBoldItalic),
            8 => Ok(Font::Courier),
            9 => Ok(Font::CourierBold),
            10 => Ok(Font::CourierOblique),
            11 => Ok(Font::CourierBoldOblique),
            12 => Ok(Font::Symbol),
            13 => Ok(Font::ZapfDingbats),
            _ => Err(CpdfError::InvalidArgument(format!(
                "unknown font code: {code}"
            ))),
        }
    }

    fn avg_char_width(&self) -> f64 {
        match self {
            Font::Courier | Font::CourierBold | Font::CourierOblique | Font::CourierBoldOblique => {
                0.6
            }
            Font::Helvetica | Font::HelveticaOblique => 0.52,
            Font::HelveticaBold | Font::HelveticaBoldOblique => 0.55,
            Font::TimesRoman | Font::TimesItalic => 0.50,
            Font::TimesBold | Font::TimesBoldItalic => 0.53,
            _ => 0.52,
        }
    }
}

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

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

fn mediabox_dims(pdf_bytes: &[u8]) -> (f64, f64) {
    let text = String::from_utf8_lossy(pdf_bytes);
    if let Some(idx) = text.find("/MediaBox") {
        let rest = &text[idx + 9..];
        if let Some(open) = rest.find('[') {
            let close = rest[open..].find(']').unwrap_or(rest.len());
            let nums: Vec<f64> = rest[open + 1..open + close]
                .split_whitespace()
                .filter_map(|s| s.parse().ok())
                .collect();
            if nums.len() >= 4 {
                return (nums[2] - nums[0], nums[3] - nums[1]);
            }
        }
    }
    (612.0, 792.0)
}

/// Resolve (x, y) for a `Position` given page dimensions and text metrics.
fn resolve_position(
    pos: &Position,
    page_w: f64,
    page_h: f64,
    text_width: f64,
    font_size: f64,
) -> (f64, f64) {
    let margin = match pos {
        Position::TopLeft(m)
        | Position::Top(m)
        | Position::TopRight(m)
        | Position::Left(m)
        | Position::Right(m)
        | Position::BottomLeft(m)
        | Position::Bottom(m)
        | Position::BottomRight(m) => *m,
        _ => 0.0,
    };

    match pos {
        Position::TopLeft(_) => (margin, page_h - margin - font_size),
        Position::Top(_) => ((page_w - text_width) / 2.0, page_h - margin - font_size),
        Position::TopRight(_) => (page_w - margin - text_width, page_h - margin - font_size),
        Position::Left(_) => (margin, (page_h - font_size) / 2.0),
        Position::Centre => ((page_w - text_width) / 2.0, (page_h - font_size) / 2.0),
        Position::Right(_) => (page_w - margin - text_width, (page_h - font_size) / 2.0),
        Position::BottomLeft(_) => (margin, margin),
        Position::Bottom(_) => ((page_w - text_width) / 2.0, margin),
        Position::BottomRight(_) => (page_w - margin - text_width, margin),
        Position::Diagonal | Position::ReverseDiagonal => {
            ((page_w - text_width) / 2.0, (page_h - font_size) / 2.0)
        }
        Position::Absolute(x, y) => (*x, *y),
    }
}

fn diagonal_angle(pos: &Position, page_w: f64, page_h: f64) -> Option<f64> {
    match pos {
        Position::Diagonal => Some(page_h.atan2(page_w).to_degrees()),
        Position::ReverseDiagonal => Some(-page_h.atan2(page_w).to_degrees()),
        _ => None,
    }
}

fn find_page_type_idx(text: &str) -> Option<usize> {
    for pattern in &["/Type/Page", "/Type /Page"] {
        let pat_len = pattern.len();
        let mut pos = 0;
        while let Some(idx) = text[pos..].find(pattern) {
            let abs = pos + idx;
            let after_len = abs + pat_len;
            if after_len >= text.len() {
                return Some(abs);
            }
            let next_char = text.as_bytes()[after_len];
            if next_char != b's' {
                return Some(abs);
            }
            pos = after_len;
        }
    }
    None
}

fn build_font_resource(font: &Font) -> String {
    format!(
        "/F_cpdf << /Type /Font /Subtype /Type1 /BaseFont /{} >>",
        font.pdf_name()
    )
}

/// Build a content-stream snippet that writes text.
fn text_content_stream(
    text: &str,
    x: f64,
    y: f64,
    font_size: f64,
    colour: &Colour,
    opacity: f64,
    rotation_deg: Option<f64>,
) -> String {
    let mut ops = String::new();

    if opacity < 1.0 {
        ops.push_str(&format!("/GS_cpdf gs\n"));
    }

    ops.push_str("BT\n");
    ops.push_str(&format!("/F_cpdf {} Tf\n", font_size));
    ops.push_str(&colour.to_fill_ops());
    ops.push('\n');

    if let Some(deg) = rotation_deg {
        let rad = deg.to_radians();
        let cos = rad.cos();
        let sin = rad.sin();
        ops.push_str(&format!(
            "{cos:.6} {sin:.6} {nsin:.6} {cos2:.6} {x:.2} {y:.2} Tm\n",
            cos = cos,
            sin = sin,
            nsin = -sin,
            cos2 = cos,
            x = x,
            y = y,
        ));
    } else {
        ops.push_str(&format!("{x:.2} {y:.2} Td\n"));
    }

    ops.push_str(&format!("({}) Tj\n", escape_pdf(text)));
    ops.push_str("ET\n");

    ops
}

/// Build the resources dictionary additions for the stamped content.
fn stamp_resources(font: &Font, opacity: f64) -> String {
    let mut res = format!("/Font << {} >>", build_font_resource(font));
    if opacity < 1.0 {
        res.push_str(&format!(
            " /ExtGState << /GS_cpdf << /Type /ExtGState /ca {} >> >>",
            opacity
        ));
    }
    res
}

fn inject_content_and_resources(
    page_pdf: &[u8],
    content: &str,
    font: &Font,
    opacity: f64,
    underneath: bool,
) -> Result<Vec<u8>> {
    let text = String::from_utf8_lossy(page_pdf);

    // Build a new content stream object
    let mut max_obj = 0usize;
    let mut search_pos = 0;
    while let Some(idx) = text[search_pos..].find(" 0 obj") {
        let abs = search_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_obj {
                max_obj = n;
            }
        }
        search_pos = abs + 6;
    }

    let new_stream_obj = max_obj + 1;
    let new_res_obj = max_obj + 2;

    let stream_content = content.as_bytes();
    let stream_obj_str = format!(
        "{new_stream_obj} 0 obj\n<< /Length {} >>\nstream\n",
        stream_content.len()
    );

    let res_dict = stamp_resources(font, opacity);
    let res_obj_str = format!("{new_res_obj} 0 obj\n<< {res_dict} >>\nendobj\n\n");

    // Find page dict — match "/Type /Page" NOT followed by 's' (which would be /Pages)
    let page_type_idx =
        find_page_type_idx(&text).ok_or_else(|| CpdfError::Parse("no /Type /Page found".into()))?;

    let dict_start = text[..page_type_idx]
        .rfind("<<")
        .ok_or_else(|| CpdfError::Parse("page dict start not found".into()))?;
    let dict_end_offset = text[page_type_idx..]
        .find(">>")
        .ok_or_else(|| CpdfError::Parse("page dict end not found".into()))?;
    let dict_end = page_type_idx + dict_end_offset;
    let page_dict = &text[dict_start..dict_end + 2];

    // Get existing /Contents reference
    let existing_contents = if let Some(ci) = page_dict.find("/Contents") {
        let rest = &page_dict[ci + 9..];
        let trimmed = rest.trim_start();
        if trimmed.starts_with('[') {
            let close = trimmed.find(']').unwrap_or(trimmed.len());
            trimmed[..close + 1].to_string()
        } else {
            let end = trimmed
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(trimmed.len());
            trimmed[..end].trim().to_string()
        }
    } else {
        String::new()
    };

    // Build new /Contents array with ordering
    let new_contents = if existing_contents.is_empty() {
        format!("{new_stream_obj} 0 R")
    } else if underneath {
        format!("[{new_stream_obj} 0 R {existing_contents}]")
    } else {
        format!("[{existing_contents} {new_stream_obj} 0 R]")
    };

    // Build updated page dict
    let mut new_page_dict = page_dict.to_string();
    if let Some(ci) = new_page_dict.find("/Contents") {
        let rest = &new_page_dict[ci + 9..];
        let trimmed = rest.trim_start();
        let end = if trimmed.starts_with('[') {
            trimmed.find(']').unwrap_or(trimmed.len()) + 1
        } else {
            trimmed
                .find(|c: char| c == '/' || c == '>')
                .unwrap_or(trimmed.len())
        };
        let contents_start = ci + 9 + (rest.len() - trimmed.len());
        let contents_end = contents_start + end;
        new_page_dict = format!(
            "{}/Contents {} {}",
            &new_page_dict[..ci],
            new_contents,
            &new_page_dict[contents_end..]
        );
    } else {
        let close = new_page_dict.rfind(">>").unwrap_or(new_page_dict.len());
        new_page_dict = format!(
            "{}\n/Contents {new_stream_obj} 0 R\n>>",
            &new_page_dict[..close]
        );
    }

    // Add /Resources reference
    if !new_page_dict.contains("/Resources") {
        let close = new_page_dict.rfind(">>").unwrap_or(new_page_dict.len());
        new_page_dict = format!(
            "{}\n/Resources {new_res_obj} 0 R\n>>",
            &new_page_dict[..close]
        );
    }

    // Replace page dict and append new objects
    let mut result = String::with_capacity(text.len() + 500);
    result.push_str(&text[..dict_start]);
    result.push_str(&new_page_dict);
    result.push_str(&text[dict_end + 2..]);

    // Insert new objects before xref
    let insert_pos = result
        .rfind("xref")
        .or_else(|| result.rfind("startxref"))
        .unwrap_or(result.len());
    let mut new_objs = String::new();
    new_objs.push_str(&stream_obj_str);
    new_objs.push_str(&String::from_utf8_lossy(stream_content));
    new_objs.push_str("\nendstream\nendobj\n\n");
    new_objs.push_str(&res_obj_str);
    result.insert_str(insert_pos, &new_objs);

    // Rebuild xref
    let out = rebuild_xref(result.as_bytes());
    Ok(out)
}

fn rebuild_xref(data: &[u8]) -> Vec<u8> {
    let text = String::from_utf8_lossy(data);
    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);
    let body_end = text
        .rfind("xref")
        .or_else(|| text.rfind("startxref"))
        .unwrap_or(text.len());
    let mut output = data[..body_end].to_vec();

    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");
            }
        }
    }
    output.extend_from_slice(b"trailer\n");
    output.extend_from_slice(format!("<< /Size {} /Root 1 0 R >>\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
}

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

/// Stamp `over` on top of `under` for pages in range.
pub fn stamp_on(
    over: CpdfDocument,
    under: CpdfDocument,
    range: &[usize],
    _scale_to_fit: bool,
) -> Result<CpdfDocument> {
    combine_docs(under, over, range, false)
}

/// Stamp `over` under `under` for pages in range.
pub fn stamp_under(
    under: CpdfDocument,
    over: CpdfDocument,
    range: &[usize],
    _scale_to_fit: bool,
) -> Result<CpdfDocument> {
    combine_docs(under, over, range, true)
}

fn combine_docs(
    base: CpdfDocument,
    overlay: CpdfDocument,
    range: &[usize],
    underneath: bool,
) -> Result<CpdfDocument> {
    let n_base = base.page_count();
    let n_overlay = overlay.page_count();
    if n_base == 0 {
        return Err(CpdfError::InvalidArgument("base document empty".into()));
    }

    use crate::enhanced::page_copy::copy_page_to_bytes;
    use crate::enhanced::page_merge::combine_page_pdfs_bytes;

    let mut page_pdfs = Vec::with_capacity(n_base);
    for i in 0..n_base {
        let base_page = copy_page_to_bytes(&base.to_bytes(), i)
            .map_err(|e| CpdfError::Parse(format!("extract base page {}: {e}", i + 1)))?;

        if range.contains(&(i + 1)) && i < n_overlay {
            let overlay_page = copy_page_to_bytes(&overlay.to_bytes(), i.min(n_overlay - 1))
                .map_err(|e| CpdfError::Parse(format!("extract overlay page: {e}")))?;

            let overlay_stream = extract_raw_content(&overlay_page);
            if !overlay_stream.is_empty() {
                let merged = inject_raw_content(&base_page, &overlay_stream, underneath)?;
                page_pdfs.push(merged);
                continue;
            }
        }
        page_pdfs.push(base_page);
    }

    let combined =
        combine_page_pdfs_bytes(&page_pdfs).map_err(|e| CpdfError::Parse(e.to_string()))?;
    CpdfDocument::from_bytes(combined)
}

fn extract_raw_content(page_pdf: &[u8]) -> Vec<u8> {
    let text = String::from_utf8_lossy(page_pdf);
    if let Some(stream_start) = text.find("stream\n") {
        let data_start = stream_start + 7;
        if let Some(end) = text[data_start..].find("endstream") {
            return page_pdf[data_start..data_start + end].to_vec();
        }
    }
    Vec::new()
}

fn inject_raw_content(page_pdf: &[u8], extra_content: &[u8], underneath: bool) -> Result<Vec<u8>> {
    let text = String::from_utf8_lossy(page_pdf);
    if let Some(stream_start) = text.find("stream\n") {
        let data_start = stream_start + 7;
        if let Some(end_offset) = text[data_start..].find("endstream") {
            let existing = &page_pdf[data_start..data_start + end_offset];
            let mut combined = Vec::new();
            if underneath {
                combined.extend_from_slice(extra_content);
                combined.push(b'\n');
                combined.extend_from_slice(existing);
            } else {
                combined.extend_from_slice(existing);
                combined.push(b'\n');
                combined.extend_from_slice(extra_content);
            }

            // Find the dict before the stream and update /Length
            let dict_start = text[..stream_start]
                .rfind("<<")
                .ok_or_else(|| CpdfError::Parse("no dict before stream".into()))?;
            let dict_end = stream_start;

            let new_dict = format!("<< /Length {} >>", combined.len());

            let mut out = Vec::with_capacity(page_pdf.len() + extra_content.len());
            out.extend_from_slice(&page_pdf[..dict_start]);
            out.extend_from_slice(new_dict.as_bytes());
            out.push(b'\n');
            out.extend_from_slice(b"stream\n");
            out.extend_from_slice(&combined);
            let after_endstream = data_start + end_offset;
            out.extend_from_slice(&page_pdf[after_endstream..]);

            return Ok(rebuild_xref(&out));
        }
    }
    Ok(page_pdf.to_vec())
}

/// Combine all pages pair-wise.
pub fn combine_pages(
    under: CpdfDocument,
    over: CpdfDocument,
    scale_to_fit: bool,
    swap: bool,
) -> Result<CpdfDocument> {
    let n = under.page_count();
    let range: Vec<usize> = (1..=n).collect();
    if swap {
        stamp_on(under, over, &range, scale_to_fit)
    } else {
        stamp_on(over, under, &range, scale_to_fit)
    }
}

/// Add text to pages in range.
pub fn add_text(
    doc: CpdfDocument,
    range: &[usize],
    text: &str,
    position: &Position,
    _linespacing: f64,
    font_size: f64,
    font: &Font,
    colour: &Colour,
    underneath: bool,
    opacity: f64,
    _justification: Justification,
) -> Result<CpdfDocument> {
    let text_width = text.len() as f64 * font.avg_char_width() * font_size;

    pages::apply_to_pages_pub(doc, range, |page_pdf| {
        let (pw, ph) = mediabox_dims(page_pdf);
        let (x, y) = resolve_position(position, pw, ph, text_width, font_size);
        let rotation = diagonal_angle(position, pw, ph);
        let content = text_content_stream(text, x, y, font_size, colour, opacity, rotation);
        inject_content_and_resources(page_pdf, &content, font, opacity, underneath)
    })
}

/// Add page numbers.
pub fn add_page_numbers(
    doc: CpdfDocument,
    range: &[usize],
    format: &str,
    position: &Position,
    font_size: f64,
    font: &Font,
    colour: &Colour,
    start_value: usize,
) -> Result<CpdfDocument> {
    let total = doc.page_count();
    let n = doc.page_count();
    if n == 0 {
        return Err(CpdfError::InvalidArgument("document has no pages".into()));
    }

    use crate::enhanced::page_copy::copy_page_to_bytes;
    use crate::enhanced::page_merge::combine_page_pdfs_bytes;

    let page_obj_nums = pages::get_page_obj_nums_pub(&doc.to_bytes());
    let mut page_pdfs = Vec::with_capacity(n);

    for page_idx in 0..n {
        let mut page_pdf = copy_page_to_bytes(&doc.to_bytes(), page_idx)
            .map_err(|e| CpdfError::Parse(format!("extract page {}: {e}", page_idx + 1)))?;

        let extras = pages::extract_extras_for_page_pub(&doc.to_bytes(), &page_obj_nums, page_idx);
        if !extras.is_empty() {
            page_pdf = pages::modify_page_dict_pub(&page_pdf, |dict| {
                let mut d = dict.to_string();
                for (key, val) in &extras {
                    d = pages::set_dict_entry_pub(&d, key, val);
                }
                d
            })?;
        }

        if range.contains(&(page_idx + 1)) {
            let page_num = start_value + page_idx;
            let text = format
                .replace("%Page", &page_num.to_string())
                .replace("%EndPage", &total.to_string());
            let text_width = text.len() as f64 * font.avg_char_width() * font_size;
            let (pw, ph) = mediabox_dims(&page_pdf);
            let (x, y) = resolve_position(position, pw, ph, text_width, font_size);
            let content = text_content_stream(&text, x, y, font_size, colour, 1.0, None);
            page_pdf = inject_content_and_resources(&page_pdf, &content, font, 1.0, false)?;
        }

        page_pdfs.push(page_pdf);
    }

    let combined =
        combine_page_pdfs_bytes(&page_pdfs).map_err(|e| CpdfError::Parse(e.to_string()))?;
    CpdfDocument::from_bytes(combined)
}

/// Add a diagonal watermark text across all pages.
pub fn add_watermark_text(
    doc: CpdfDocument,
    text: &str,
    font: &Font,
    font_size: f64,
    colour: &Colour,
    opacity: f64,
    angle_deg: f64,
) -> Result<CpdfDocument> {
    let n = doc.page_count();
    let range: Vec<usize> = (1..=n).collect();
    let text_width = text.len() as f64 * font.avg_char_width() * font_size;

    pages::apply_to_pages_pub(doc, &range, |page_pdf| {
        let (pw, ph) = mediabox_dims(page_pdf);
        let x = (pw - text_width) / 2.0;
        let y = (ph - font_size) / 2.0;
        let content = text_content_stream(text, x, y, font_size, colour, opacity, Some(angle_deg));
        inject_content_and_resources(page_pdf, &content, font, opacity, true)
    })
}

/// Add a rectangle to pages in range.
pub fn add_rectangle(
    doc: CpdfDocument,
    range: &[usize],
    x: f64,
    y: f64,
    w: f64,
    h: f64,
    colour: &Colour,
    outline: bool,
    linewidth: f64,
    opacity: f64,
) -> Result<CpdfDocument> {
    let font = Font::Helvetica; // unused but needed for resources
    pages::apply_to_pages_pub(doc, range, |page_pdf| {
        let mut ops = String::new();
        if opacity < 1.0 {
            ops.push_str("/GS_cpdf gs\n");
        }
        ops.push_str(&format!("{w} w\n", w = linewidth));
        if outline {
            ops.push_str(&colour.to_stroke_ops());
        } else {
            ops.push_str(&colour.to_fill_ops());
        }
        ops.push('\n');
        ops.push_str(&format!("{x:.2} {y:.2} {w:.2} {h:.2} re\n"));
        if outline {
            ops.push_str("S\n");
        } else {
            ops.push_str("f\n");
        }
        inject_content_and_resources(page_pdf, &ops, &font, opacity, false)
    })
}

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

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

    fn all_pages(n: usize) -> Vec<usize> {
        (1..=n).collect()
    }

    #[test]
    fn add_text_preserves_page_count() {
        let doc = blank(2);
        let doc = add_text(
            doc,
            &all_pages(2),
            "Hello",
            &Position::Centre,
            1.0,
            12.0,
            &Font::Helvetica,
            &Colour::Grey(0.0),
            false,
            1.0,
            Justification::Left,
        )
        .unwrap();
        assert_eq!(doc.page_count(), 2);
    }

    #[test]
    fn add_rectangle_preserves_page_count() {
        let doc = blank(1);
        let doc = add_rectangle(
            doc,
            &[1],
            10.0,
            10.0,
            100.0,
            50.0,
            &Colour::Rgb(1.0, 0.0, 0.0),
            false,
            1.0,
            1.0,
        )
        .unwrap();
        assert_eq!(doc.page_count(), 1);
    }

    #[test]
    fn font_from_code_roundtrip() {
        for code in 0..14 {
            assert!(Font::from_code(code).is_ok());
        }
        assert!(Font::from_code(14).is_err());
    }

    #[test]
    fn position_resolve_centre() {
        let (x, y) = resolve_position(&Position::Centre, 612.0, 792.0, 100.0, 12.0);
        assert!((x - 256.0).abs() < 1.0);
        assert!((y - 390.0).abs() < 1.0);
    }
}
