// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
// SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>

//! Faithful Unicode PDF rendering for the Export menu.
//!
//! The `lo_writer` PDF backend uses the built-in base-14 Helvetica font with
//! StandardEncoding, which cannot represent German umlauts (`ä ö ü ß`) and so
//! silently drops them. This module renders the PDF directly with an embedded
//! TrueType font (the bundled Roboto), as a Type0 / CIDFontType2 with
//! Identity-H encoding plus a ToUnicode CMap, so the umlauts render correctly
//! and stay selectable / extractable. Only `ttf-parser` is used — no heavy
//! PDF dependency tree.

// Low-level PDF layout is inherently arithmetic-, cast- and text-assembly
// heavy, and the doc comments carry PDF jargon (Type0, FontFile2, Identity-H,
// …). The strict numeric/style/doc lints are relaxed for this self-contained
// module only.
#![allow(
    clippy::arithmetic_side_effects,
    clippy::float_arithmetic,
    clippy::as_conversions,
    clippy::cast_precision_loss,
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    clippy::cast_possible_wrap,
    clippy::indexing_slicing,
    clippy::integer_division,
    clippy::too_many_lines,
    clippy::cognitive_complexity,
    clippy::doc_markdown,
    clippy::write_with_newline,
    clippy::suboptimal_flops,
    clippy::too_many_arguments
)]

use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;

use ttf_parser::{Face, GlyphId};

/// Bundled body font (covers Latin + German letters).
const ROBOTO_REGULAR: &[u8] = include_bytes!("static/fonts/roboto/roboto-regular.ttf");
/// Bundled heading font.
const ROBOTO_BOLD: &[u8] = include_bytes!("static/fonts/roboto/roboto-bold.ttf");

/// A4 page width in PostScript points.
const PAGE_W: f32 = 595.0;
/// A4 page height in PostScript points.
const PAGE_H: f32 = 842.0;
/// Page margin in points.
const MARGIN: f32 = 56.0;
/// Body text size in points.
const BODY_SIZE: f32 = 10.5;

/// One block of document content.
pub enum Block {
    /// A heading at the given level (1 = practice, 2 = control, 3 = section).
    Heading(u8, String),
    /// A body paragraph.
    Paragraph(String),
}

/// A PDF rendering error.
#[derive(Debug)]
pub enum PdfError {
    /// An embedded font could not be parsed.
    Font(String),
}

impl fmt::Display for PdfError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Font(message) => write!(f, "PDF font error: {message}"),
        }
    }
}

impl std::error::Error for PdfError {}

/// An embedded font plus the metrics needed to lay out and describe it.
struct PdfFont<'a> {
    /// The parsed face.
    face: Face<'a>,
    /// The raw `sfnt` bytes (embedded verbatim as `FontFile2`).
    data: &'a [u8],
}

impl<'a> PdfFont<'a> {
    /// Parse an embedded TrueType font.
    ///
    /// # Errors
    ///
    /// Returns [`PdfError::Font`] when the bytes are not a parseable font.
    fn new(data: &'a [u8]) -> Result<Self, PdfError> {
        let face = Face::parse(data, 0).map_err(|error| PdfError::Font(error.to_string()))?;
        Ok(Self { face, data })
    }

    /// Units per em as a float (Roboto: 2048).
    fn upem(&self) -> f32 {
        f32::from(self.face.units_per_em())
    }

    /// The glyph id for `ch`, if the font has it.
    fn glyph(&self, ch: char) -> Option<u16> {
        self.face.glyph_index(ch).map(|glyph| glyph.0)
    }

    /// The advance of glyph `gid` in font units.
    fn advance(&self, gid: u16) -> f32 {
        f32::from(self.face.glyph_hor_advance(GlyphId(gid)).unwrap_or(0))
    }

    /// The advance of glyph `gid` at `size` points.
    fn advance_pt(&self, gid: u16, size: f32) -> f32 {
        self.advance(gid) * size / self.upem()
    }

    /// Scale a font-unit metric to PDF text space (1000 units/em).
    fn scale(&self, value: f32) -> i32 {
        (value * 1000.0 / self.upem()).round() as i32
    }
}

/// Replace characters the bundled font lacks (or that are presentation-only)
/// with safe equivalents; everything else is left for the font to render.
fn normalize(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '\u{00A0}' | '\u{202F}' => out.push(' '), // (narrow) no-break space
            '\u{00AD}' | '\u{1F512}' => {},           // soft hyphen / lock emoji
            '\u{2011}' => out.push('-'),              // non-breaking hyphen
            '\u{2022}' => out.push('*'),              // bullet
            '\u{2265}' => out.push_str(">="),         // ≥ (absent from the subset)
            other => out.push(other),
        }
    }
    out
}

/// The pixel width of one already-normalized word at `size`.
fn word_width(font: &PdfFont<'_>, word: &str, size: f32) -> f32 {
    word.chars()
        .filter_map(|ch| font.glyph(ch))
        .map(|gid| font.advance_pt(gid, size))
        .sum()
}

/// Greedy word-wrap of `text` to `max_w`, returning the lines.
fn wrap(font: &PdfFont<'_>, text: &str, size: f32, max_w: f32) -> Vec<String> {
    let normalized = normalize(text);
    let space_w = font
        .glyph(' ')
        .map_or(0.0, |gid| font.advance_pt(gid, size));
    let mut lines: Vec<String> = Vec::new();
    let mut line = String::new();
    let mut line_w = 0.0_f32;
    for word in normalized.split_whitespace() {
        let ww = word_width(font, word, size);
        if line.is_empty() {
            line.push_str(word);
            line_w = ww;
        } else if line_w + space_w + ww > max_w {
            lines.push(std::mem::take(&mut line));
            line.push_str(word);
            line_w = ww;
        } else {
            line.push(' ');
            line.push_str(word);
            line_w += space_w + ww;
        }
    }
    if !line.is_empty() {
        lines.push(line);
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

/// Encode one line as an Identity-H hex string of 2-byte glyph ids.
fn encode_line(font: &PdfFont<'_>, line: &str) -> String {
    let mut hex = String::new();
    for ch in line.chars() {
        if let Some(gid) = font.glyph(ch) {
            let _ = write!(hex, "{gid:04X}");
        }
    }
    hex
}

/// Layout state: emits text into per-page content streams.
struct Layout<'a> {
    /// Regular body font.
    regular: &'a PdfFont<'a>,
    /// Bold heading font.
    bold: &'a PdfFont<'a>,
    /// Completed page content streams.
    pages: Vec<String>,
    /// The page currently being filled.
    current: String,
    /// Baseline y of the next line (PDF coords, origin bottom-left).
    cursor_y: f32,
}

impl<'a> Layout<'a> {
    /// Start a fresh layout with one empty page.
    fn new(regular: &'a PdfFont<'a>, bold: &'a PdfFont<'a>) -> Self {
        Self {
            regular,
            bold,
            pages: Vec::new(),
            current: String::new(),
            cursor_y: PAGE_H - MARGIN,
        }
    }

    /// The usable text width.
    fn content_width() -> f32 {
        PAGE_W - 2.0 * MARGIN
    }

    /// Finish the current page and begin a new one.
    fn new_page(&mut self) {
        self.pages.push(std::mem::take(&mut self.current));
        self.cursor_y = PAGE_H - MARGIN;
    }

    /// Emit one wrapped block of text in the given font and size, with the
    /// given space before it.
    fn emit(
        &mut self,
        font: &PdfFont<'_>,
        resource: &str,
        size: f32,
        text: &str,
        space_before: f32,
    ) {
        let line_height = size * 1.32;
        self.cursor_y -= space_before;
        for line in wrap(font, text, size, Self::content_width()) {
            if self.cursor_y - line_height < MARGIN {
                self.new_page();
            }
            let hex = encode_line(font, &line);
            let y = self.cursor_y;
            let _ = write!(
                self.current,
                "BT\n/{resource} {size} Tf\n1 0 0 1 {MARGIN:.2} {y:.2} Tm\n<{hex}> Tj\nET\n",
            );
            self.cursor_y -= line_height;
        }
    }

    /// Render the title and every block, then return the page streams.
    fn run(mut self, title: &str, blocks: &[Block]) -> Vec<String> {
        self.emit(self.bold, "F2", 17.0, title, 0.0);
        for block in blocks {
            match block {
                Block::Heading(level, text) => {
                    let size = match level {
                        1 => 14.0,
                        2 => 12.0,
                        _ => 11.0,
                    };
                    self.emit(self.bold, "F2", size, text, size * 0.6);
                },
                Block::Paragraph(text) => {
                    self.emit(self.regular, "F1", BODY_SIZE, text, BODY_SIZE * 0.4);
                },
            }
        }
        self.pages.push(std::mem::take(&mut self.current));
        self.pages
    }
}

/// Invert a font's cmap to a glyph-id → Unicode map (BMP only).
fn glyph_to_unicode(face: &Face<'_>) -> BTreeMap<u16, u32> {
    let mut map: BTreeMap<u16, u32> = BTreeMap::new();
    if let Some(cmap) = face.tables().cmap {
        for subtable in cmap.subtables {
            if !subtable.is_unicode() {
                continue;
            }
            subtable.codepoints(|cp| {
                if cp <= 0xFFFF {
                    if let Some(glyph) = subtable.glyph_index(cp) {
                        map.entry(glyph.0).or_insert(cp);
                    }
                }
            });
        }
    }
    map
}

/// Build the `/W` glyph-width array body (`0 [w0 w1 …]`).
fn widths_array(font: &PdfFont<'_>) -> String {
    let count = font.face.number_of_glyphs();
    let mut out = String::from("0 [");
    for gid in 0..count {
        let _ = write!(out, "{} ", font.scale(font.advance(gid)));
    }
    out.push(']');
    out
}

/// Build the ToUnicode CMap stream body for a font.
fn to_unicode_cmap(font: &PdfFont<'_>) -> String {
    let map = glyph_to_unicode(&font.face);
    let entries: Vec<(u16, u32)> = map.into_iter().collect();
    let mut body = String::from(
        "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
         /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
         /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
         1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
    );
    for chunk in entries.chunks(100) {
        let _ = write!(body, "{} beginbfchar\n", chunk.len());
        for (gid, cp) in chunk {
            let _ = write!(body, "<{gid:04X}> <{cp:04X}>\n");
        }
        body.push_str("endbfchar\n");
    }
    body.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
    body
}

/// Accumulates numbered PDF objects and emits the final file with xref.
struct Writer {
    /// The output buffer.
    buf: Vec<u8>,
    /// Byte offset of each object (index = object number - 1).
    offsets: Vec<usize>,
}

impl Writer {
    /// Start with the PDF header.
    fn new() -> Self {
        Self {
            buf: b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(),
            offsets: Vec::new(),
        }
    }

    /// Append object `number` with a textual body.
    fn object(&mut self, number: usize, body: &str) {
        self.begin(number);
        self.buf.extend_from_slice(body.as_bytes());
        self.buf.extend_from_slice(b"\nendobj\n");
    }

    /// Append a stream object with a dict prefix and raw (possibly binary) data.
    fn stream(&mut self, number: usize, dict: &str, data: &[u8]) {
        self.begin(number);
        self.buf.extend_from_slice(dict.as_bytes());
        self.buf.extend_from_slice(b"\nstream\n");
        self.buf.extend_from_slice(data);
        self.buf.extend_from_slice(b"\nendstream\nendobj\n");
    }

    /// Record the offset and write the `N 0 obj` header.
    fn begin(&mut self, number: usize) {
        while self.offsets.len() < number {
            self.offsets.push(0);
        }
        self.offsets[number - 1] = self.buf.len();
        self.buf
            .extend_from_slice(format!("{number} 0 obj\n").as_bytes());
    }

    /// Write the xref table and trailer, returning the complete file.
    fn finish(mut self, root: usize) -> Vec<u8> {
        let count = self.offsets.len();
        let xref_offset = self.buf.len();
        self.buf
            .extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", count + 1).as_bytes());
        for offset in &self.offsets {
            self.buf
                .extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
        }
        self.buf.extend_from_slice(
            format!(
                "trailer\n<< /Size {} /Root {root} 0 R >>\nstartxref\n{xref_offset}\n%%EOF",
                count + 1
            )
            .as_bytes(),
        );
        self.buf
    }
}

/// The five object numbers that make up one embedded font.
struct FontObjs {
    /// Type0 font object.
    type0: usize,
    /// CIDFontType2 descendant.
    cid: usize,
    /// Font descriptor.
    descriptor: usize,
    /// `FontFile2` stream.
    file: usize,
    /// ToUnicode CMap stream.
    to_unicode: usize,
}

/// Write the five objects describing one embedded font.
fn write_font(writer: &mut Writer, font: &PdfFont<'_>, objs: &FontObjs) {
    writer.object(
        objs.type0,
        &format!(
            "<< /Type /Font /Subtype /Type0 /BaseFont /Roboto /Encoding /Identity-H \
             /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >>",
            objs.cid, objs.to_unicode
        ),
    );
    writer.object(
        objs.cid,
        &format!(
            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Roboto \
             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
             /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW {} /W [{}] >>",
            objs.descriptor,
            font.scale(font.advance(0)),
            widths_array(font),
        ),
    );
    let bbox = font.face.global_bounding_box();
    writer.object(
        objs.descriptor,
        &format!(
            "<< /Type /FontDescriptor /FontName /Roboto /Flags 32 \
             /FontBBox [{} {} {} {}] /ItalicAngle 0 /Ascent {} /Descent {} \
             /CapHeight {} /StemV 80 /FontFile2 {} 0 R >>",
            font.scale(f32::from(bbox.x_min)),
            font.scale(f32::from(bbox.y_min)),
            font.scale(f32::from(bbox.x_max)),
            font.scale(f32::from(bbox.y_max)),
            font.scale(f32::from(font.face.ascender())),
            font.scale(f32::from(font.face.descender())),
            font.scale(f32::from(
                font.face
                    .capital_height()
                    .unwrap_or_else(|| font.face.ascender()),
            )),
            objs.file,
        ),
    );
    writer.stream(
        objs.file,
        &format!(
            "<< /Length {} /Length1 {} >>",
            font.data.len(),
            font.data.len()
        ),
        font.data,
    );
    let cmap = to_unicode_cmap(font);
    writer.stream(
        objs.to_unicode,
        &format!("<< /Length {} >>", cmap.len()),
        cmap.as_bytes(),
    );
}

/// Render `title` + `blocks` to a self-contained PDF with embedded fonts.
///
/// # Errors
///
/// Returns [`PdfError::Font`] when a bundled font fails to parse.
pub fn render(title: &str, blocks: &[Block]) -> Result<Vec<u8>, PdfError> {
    let regular = PdfFont::new(ROBOTO_REGULAR)?;
    let bold = PdfFont::new(ROBOTO_BOLD)?;

    let page_streams = Layout::new(&regular, &bold).run(title, blocks);
    let page_count = page_streams.len();

    // Fixed object numbers: 1 catalog, 2 pages, 3-7 regular, 8-12 bold,
    // then page/content pairs from 13.
    let regular_objs = FontObjs {
        type0: 3,
        cid: 4,
        descriptor: 5,
        file: 6,
        to_unicode: 7,
    };
    let bold_objs = FontObjs {
        type0: 8,
        cid: 9,
        descriptor: 10,
        file: 11,
        to_unicode: 12,
    };

    let mut kids = String::new();
    for index in 0..page_count {
        let _ = write!(kids, "{} 0 R ", 13 + index * 2);
    }

    let mut writer = Writer::new();
    writer.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
    writer.object(
        2,
        &format!("<< /Type /Pages /Kids [{kids}] /Count {page_count} >>"),
    );
    write_font(&mut writer, &regular, &regular_objs);
    write_font(&mut writer, &bold, &bold_objs);

    for (index, content) in page_streams.iter().enumerate() {
        let page_obj = 13 + index * 2;
        let content_obj = 14 + index * 2;
        writer.object(
            page_obj,
            &format!(
                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] \
                 /Resources << /Font << /F1 {} 0 R /F2 {} 0 R >> >> /Contents {content_obj} 0 R >>",
                regular_objs.type0, bold_objs.type0,
            ),
        );
        writer.stream(
            content_obj,
            &format!("<< /Length {} >>", content.len()),
            content.as_bytes(),
        );
    }

    Ok(writer.finish(1))
}

#[cfg(test)]
mod tests {
    #![allow(clippy::missing_panics_doc)] // tests assert; panicking is the failure mode

    use super::{render, Block};

    #[test]
    fn renders_a_valid_pdf_with_embedded_font() {
        let blocks = vec![
            Block::Heading(2, "GC.5.1.1 — Festlegung der Geschäftsprozesse".to_owned()),
            Block::Paragraph(
                "Governance und Compliance MUSS die Geschäftsprozesse die für den \
                 Geltungsbereich relevant sind festlegen."
                    .to_owned(),
            ),
        ];
        let pdf = render("BSI Grundschutz++", &blocks).expect("renders");
        assert!(pdf.starts_with(b"%PDF-1.7"), "pdf header");
        assert!(pdf.ends_with(b"%%EOF"), "pdf trailer");
        let text = String::from_utf8_lossy(&pdf);
        assert!(text.contains("/Subtype /Type0"), "embedded Type0 font");
        assert!(text.contains("Identity-H"), "Identity-H encoding");
        assert!(text.contains("/FontFile2"), "embedded font file");
    }

    #[test]
    fn umlauts_are_mapped_for_extraction() {
        // The ToUnicode CMap must map the umlaut glyphs to their real code
        // points so the PDF text is faithful and extractable.
        let pdf = render(
            "T",
            &[Block::Paragraph("Geschäftsprozesse für groß".to_owned())],
        )
        .expect("renders");
        let text = String::from_utf8_lossy(&pdf);
        for cp in ["00E4", "00FC", "00DF"] {
            assert!(text.contains(cp), "ToUnicode must map U+{cp}");
        }
    }
}
