package de.frigosped.dc.service;

import jakarta.enterprise.context.ApplicationScoped;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
import org.jboss.logging.Logger;

import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
 * Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox).
 *
 * Unterstützte Markdown-Elemente:
 *   # ## ### ####   Überschriften (4 Ebenen)
 *   **text**        Fett
 *   *text*          Kursiv
 *   ---             Trennlinie
 *   > text          Zitat / Blockquote
 *   | a | b |       Tabelle
 *   ✅ ❌ ⚠️ ❓ 📄  Fünf Report-Emoji → werden als farbige Icons gerendert
 *   Leerzeile       Vertikaler Abstand
 *
 * Einstiegspunkte:
 *   toDocx(markdown) → byte[]   DOCX-Binärdaten
 *   toPdf(markdown)  → byte[]   PDF-Binärdaten
 */
@ApplicationScoped
public class MarkdownExportService {

    private static final Logger LOG = Logger.getLogger(MarkdownExportService.class);

    // =========================================================================
    // Inline-Segment-Modell (gemeinsam für DOCX und PDF)
    // =========================================================================

    /** Typ eines Inline-Abschnitts innerhalb einer Zeile. */
    enum SegType { PLAIN, BOLD, ITALIC, EMOJI }

    /** Ein Inline-Abschnitt mit Typ und Rohtext. */
    record Seg(SegType type, String text) {}

    /**
     * Whitelist der fünf Report-Emoji-Codepoints.
     * Nur diese werden als Icon-Bild gerendert. Alle anderen Unicode-Sonderzeichen
     * (z.B. ● U+25CF, ™ U+2122) bleiben als PLAIN-Text und werden nicht angefasst.
     */
    private static boolean isReportEmoji(int cp) {
        return cp == 0x2705    // ✅
            || cp == 0x274C    // ❌
            || cp == 0x26A0    // ⚠
            || cp == 0x2753    // ❓
            || cp == 0x1F4C4;  // 📄
    }

    /** Findet das schließende einzelne * ab Position from; überspringt ** -Paare. */
    private static int findItalicClose(String text, int from) {
        int end = text.indexOf('*', from);
        while (end >= 0) {
            if (end + 1 >= text.length() || text.charAt(end + 1) != '*') return end;
            end = text.indexOf('*', end + 2);
        }
        return -1;
    }

    /**
     * Zerlegt text in typisierte Inline-Segmente mit Zeilenübergreifender Format-Fortsetzung.
     *
     * openFormatRef[0] gibt das ggf. bereits offene Format vom vorherigen Aufruf an
     * (null = keines, BOLD = innerhalb **, ITALIC = innerhalb *).
     * Nach dem Aufruf enthält openFormatRef[0] das weiterhin offene Format.
     *
     * Wird ** oder * geöffnet aber auf dieser Zeile nicht geschlossen, erstreckt sich
     * die Formatierung bis ans Zeilenende und openFormatRef[0] wird entsprechend gesetzt,
     * damit der nächste Aufruf die Formatierung nahtlos fortsetzt.
     */
    static List<Seg> parseSegments(String text, SegType[] openFormatRef) {
        List<Seg> out = new ArrayList<>();
        if (text == null || text.isEmpty()) return out;

        SegType open = openFormatRef[0];
        int pos = 0;

        // Format-Fortsetzung von der Vorgängerzeile aufnehmen
        if (open == SegType.BOLD) {
            int end = text.indexOf("**");
            if (end >= 0) {
                if (end > 0) out.add(new Seg(SegType.BOLD, text.substring(0, end)));
                pos = end + 2;
                open = null;
            } else {
                out.add(new Seg(SegType.BOLD, text));
                openFormatRef[0] = SegType.BOLD;
                return out;
            }
        } else if (open == SegType.ITALIC) {
            int end = findItalicClose(text, 0);
            if (end >= 0) {
                if (end > 0) out.add(new Seg(SegType.ITALIC, text.substring(0, end)));
                pos = end + 1;
                open = null;
            } else {
                out.add(new Seg(SegType.ITALIC, text));
                openFormatRef[0] = SegType.ITALIC;
                return out;
            }
        }

        while (pos < text.length()) {
            // **bold**
            if (text.startsWith("**", pos)) {
                int end = text.indexOf("**", pos + 2);
                if (end >= 0) {
                    out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end)));
                    pos = end + 2;
                } else {
                    // kein schließendes ** auf dieser Zeile → bis Zeilenende + Folgezeile
                    String boldText = text.substring(pos + 2);
                    if (!boldText.isEmpty()) out.add(new Seg(SegType.BOLD, boldText));
                    open = SegType.BOLD;
                    pos = text.length();
                }
                continue;
            }
            // *italic*  (not part of **)
            if (text.charAt(pos) == '*'
                    && (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) {
                int end = findItalicClose(text, pos + 1);
                if (end >= 0) {
                    out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end)));
                    pos = end + 1;
                } else {
                    // kein schließendes * auf dieser Zeile → bis Zeilenende + Folgezeile
                    String italicText = text.substring(pos + 1);
                    if (!italicText.isEmpty()) out.add(new Seg(SegType.ITALIC, italicText));
                    open = SegType.ITALIC;
                    pos = text.length();
                }
                continue;
            }
            // Bekanntes Report-Emoji
            int cp = text.codePointAt(pos);
            if (isReportEmoji(cp)) {
                StringBuilder eb = new StringBuilder();
                while (pos < text.length()) {
                    int c = text.codePointAt(pos);
                    if (!isReportEmoji(c) && c != 0xFE0F && c != 0x200D) break;
                    eb.appendCodePoint(c);
                    pos += Character.charCount(c);
                }
                out.add(new Seg(SegType.EMOJI, eb.toString()));
                continue;
            }
            // Klartext bis zum nächsten Marker
            int start = pos;
            while (pos < text.length()) {
                int c = text.codePointAt(pos);
                if (isReportEmoji(c) || text.charAt(pos) == '*') break;
                pos += Character.charCount(c);
            }
            if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos)));
        }

        openFormatRef[0] = open;
        return out;
    }

    /** Überladung ohne Zustandsverfolgung (für Tabellenzellen und isolierte Aufrufe). */
    static List<Seg> parseSegments(String text) {
        return parseSegments(text, new SegType[]{null});
    }

    // =========================================================================
    // DOCX-Export via Apache POI
    // =========================================================================
    //
    // Aufbau:
    //   toDocx()
    //     └── Hauptschleife über Zeilen
    //           ├── addHeading()       → XWPFParagraph mit Heading-Style + Run
    //           ├── addParagraph()     → renderInline() → ein Run pro Segment
    //           ├── addHorizontalRule()→ Paragraph mit Strich-Zeichen
    //           ├── addBlockquote()    → eingerückter Paragraph + grauer Hintergrund
    //           └── addTable()         → XWPFTable; Zellen via renderInline()
    //
    // Inline-Formatierung (renderInline):
    //   parseSegments() liefert die Segmente; für jedes wird ein eigener XWPFRun
    //   erzeugt und run.setBold() / run.setItalic() gesetzt.
    //   EMOJI-Segmente landen als Rohtext im Run (Word kann die Codepoints direkt anzeigen).
    // =========================================================================

    /**
     * Erzeugt ein DOCX-Dokument aus dem Markdown-String.
     * \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
     */
    public byte[] toDocx(String markdown) throws Exception {
        try (XWPFDocument doc = new XWPFDocument()) {
            String[] lines = markdown.replace("\r", "").split("\n", -1);
            int i = 0;
            SegType[] inlineState = {null}; // zeilenübergreifendes Bold/Italic-State
            while (i < lines.length) {
                String line = lines[i];

                if (line.startsWith("#### ")) {
                    inlineState[0] = null;
                    addHeading(doc, line.substring(5).trim(), 4);
                } else if (line.startsWith("### ")) {
                    inlineState[0] = null;
                    addHeading(doc, line.substring(4).trim(), 3);
                } else if (line.startsWith("## ")) {
                    inlineState[0] = null;
                    addHeading(doc, line.substring(3).trim(), 2);
                } else if (line.startsWith("# ")) {
                    inlineState[0] = null;
                    addHeading(doc, line.substring(2).trim(), 1);
                } else if (line.equals("---")) {
                    inlineState[0] = null;
                    addHorizontalRule(doc);
                } else if (line.startsWith("> ")) {
                    inlineState[0] = null;
                    addBlockquote(doc, line.substring(2).trim());
                } else if (line.startsWith("|")) {
                    inlineState[0] = null;
                    // Tabellenzeilen sammeln bis die Pipe-Sequenz endet
                    List<String[]> rows = new ArrayList<>();
                    while (i < lines.length && lines[i].startsWith("|")) {
                        if (!isTableSeparator(lines[i])) {
                            String[] parts = lines[i].split("\\|", -1);
                            List<String> cells = new ArrayList<>();
                            for (int p = 1; p < parts.length - 1; p++)
                                cells.add(parts[p].trim());
                            if (!cells.isEmpty()) rows.add(cells.toArray(new String[0]));
                        }
                        i++;
                    }
                    if (!rows.isEmpty()) addTable(doc, rows);
                    continue; // i wurde schon inkrementiert
                } else if (!line.isBlank()) {
                    addParagraph(doc, line, inlineState);
                } else {
                    inlineState[0] = null; // Leerzeile = Absatzgrenze → Format zurücksetzen
                }
                i++;
            }
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            doc.write(baos);
            return baos.toByteArray();
        }
    }

    /** Trennzeilen wie |:---|:---| überspringen; müssen Bindestriche enthalten. */
    private static boolean isTableSeparator(String line) {
        return line.matches("\\|[-: |]+\\|") && line.contains("-");
    }

    /**
     * Fügt eine Überschrift hinzu.
     * Setzt den Word-Style "Heading1"–"Heading4" und wählt die Schriftgröße:
     *   Level 1 = 22pt, 2 = 18pt, 3 = 14pt, 4 = 12pt.
     */
    private void addHeading(XWPFDocument doc, String text, int level) {
        XWPFParagraph p = doc.createParagraph();
        p.setStyle("Heading" + level);
        XWPFRun run = p.createRun();
        run.setText(text);
        run.setBold(true);
        run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; });
    }

    /** Fügt einen normalen Absatz hinzu; state wird für zeilenübergreifende Formatierung mitgeführt. */
    private void addParagraph(XWPFDocument doc, String text, SegType[] state) {
        renderInline(doc.createParagraph(), text, state);
    }

    private void addParagraph(XWPFDocument doc, String text) {
        renderInline(doc.createParagraph(), text);
    }

    /**
     * Rendert Inline-Formatierung in einen vorhandenen Paragraph.
     * state wird an parseSegments() weitergereicht und hält das über Zeilen offene Format.
     */
    private void renderInline(XWPFParagraph p, String text, SegType[] state) {
        for (Seg seg : parseSegments(text, state)) {
            XWPFRun run = p.createRun();
            run.setText(seg.text());
            if (seg.type() == SegType.BOLD)   run.setBold(true);
            if (seg.type() == SegType.ITALIC) run.setItalic(true);
        }
    }

    /** Überladung ohne Zustandsverfolgung (für Tabellenzellen). */
    private void renderInline(XWPFParagraph p, String text) {
        renderInline(p, text, new SegType[]{null});
    }

    /**
     * Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border).
     */
    private void addHorizontalRule(XWPFDocument doc) {
        doc.createParagraph().createRun()
           .setText("──────────────────────────────────────────────");
    }

    /**
     * Fügt einen eingerückten Blockquote-Absatz ein.
     * Einrückung: 720 Twips (= 1,27 cm), Hintergrund: #F0F0F0, Text kursiv.
     */
    private void addBlockquote(XWPFDocument doc, String text) {
        XWPFParagraph p = doc.createParagraph();
        p.setIndentationLeft(720);
        try {
            CTShd shd = p.getCTP().addNewPPr().addNewShd();
            shd.setVal(STShd.CLEAR); shd.setColor("auto"); shd.setFill("F0F0F0");
        } catch (Exception ignored) {}
        XWPFRun run = p.createRun();
        run.setItalic(true);
        run.setText(text);
    }

    /**
     * Fügt eine Tabelle ein.
     * Wichtig: Zellen werden über renderInline() befüllt, nicht über cell.setText(),
     * damit Bold/Italic innerhalb von Tabellenzellen korrekt angewandt werden.
     */
    private void addTable(XWPFDocument doc, List<String[]> rows) {
        if (rows.isEmpty()) return;
        int cols = rows.get(0).length;
        XWPFTable table = doc.createTable(rows.size(), cols);
        table.setWidth("100%");
        for (int r = 0; r < rows.size(); r++) {
            XWPFTableRow row = table.getRow(r);
            String[] cells = rows.get(r);
            for (int c = 0; c < cols; c++) {
                XWPFTableCell cell = c < row.getTableCells().size()
                        ? row.getCell(c) : row.addNewTableCell();
                List<XWPFParagraph> paras = cell.getParagraphs();
                XWPFParagraph para = paras.isEmpty() ? cell.addParagraph() : paras.get(0);
                // Vorhandene (leere) Runs entfernen bevor renderInline neue anlegt
                while (!para.getRuns().isEmpty()) para.removeRun(0);
                if (c < cells.length) renderInline(para, cells[c]);
            }
        }
    }

    // =========================================================================
    // PDF-Export via PDFBox
    // =========================================================================
    //
    // Koordinatensystem: PDFBox nutzt PDF-Koordinaten (0,0 = unten links).
    //   y startet bei PAGE_HEIGHT - MARGIN (oben) und läuft nach unten.
    //   newPage() setzt y zurück auf PAGE_HEIGHT - MARGIN.
    //
    // Aufrufkette für eine normale Zeile:
    //   write()
    //     └── renderLine()             zerlegt und bricht Zeile um
    //           ├── writeWrappedText() reiner Text ohne Formatierung
    //           │     └── flushText()  schreibt eine Textzeile auf den Stream
    //           └── splitWords() + flushInlineLine() für gemischte Segmente
    //                 └── renderSegsAt()  schreibt Segmente inline nebeneinander
    //                       ├── cs.showText()   für PLAIN/BOLD/ITALIC
    //                       └── cs.drawImage()  für EMOJI (aus emojiImage())
    //
    // Emoji-Rendering:
    //   Java AWT im headless-Modus kann NotoColorEmoji (COLR v1) nicht rendern.
    //   Lösung: emojiImage() zeichnet die Icons als Java2D-Shapes in ein
    //   BufferedImage und gibt es als PDImageXObject zurück.
    // =========================================================================

    private static final float MARGIN      = 50f;
    private static final float LINE_HEIGHT = 14f;
    private static final float PAGE_WIDTH  = PDRectangle.A4.getWidth();
    private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight();
    private static final float TEXT_WIDTH  = PAGE_WIDTH - 2 * MARGIN;

    /**
     * Erzeugt ein PDF aus dem Markdown-String via PDFBox.
     * Emoji werden als farbige Java2D-Icons eingebettet (via PdfWriter.emojiImage()).
     * \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
     */
    public byte[] toPdf(String markdown) throws Exception {
        try (PDDocument doc = new PDDocument()) {
            PdfWriter writer = new PdfWriter(doc);
            writer.write(markdown.replace("\r", ""));
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            doc.save(baos);
            return baos.toByteArray();
        }
    }

    /** Zustandsbehafteter Renderer für ein einzelnes PDF-Dokument. */
    private class PdfWriter {

        final PDDocument doc;
        PDPageContentStream cs;  // aktiver Content-Stream der aktuellen Seite
        float y;                 // aktuelle y-Schreibposition (oben nach unten)
        final PDType1Font fNorm, fBold, fItalic;
        // Statischer Cache: "codepoint@px" → BufferedImage (JVM-weit, überlebt mehrere PDFs)
        private static final java.util.concurrent.ConcurrentHashMap<String, BufferedImage>
                BUFFERED_IMAGE_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
        // Pro-Dokument-Cache: "codepoint@px" → PDImageXObject (PDDocument-gebunden)
        private final java.util.Map<String, PDImageXObject> emojiCache = new java.util.HashMap<>();
        int emojiCacheMisses = 0;

        PdfWriter(PDDocument doc) throws Exception {
            this.doc    = doc;
            this.fNorm  = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
            this.fBold  = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
            this.fItalic= new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
            newPage();
        }

        /**
         * Schließt den aktuellen Content-Stream, fügt eine neue A4-Seite ein
         * und setzt y auf den oberen Rand.
         */
        void newPage() throws Exception {
            if (cs != null) cs.close();
            PDPage p = new PDPage(PDRectangle.A4);
            doc.addPage(p);
            cs = new PDPageContentStream(doc, p);
            y = PAGE_HEIGHT - MARGIN;
            LOG.infof("PDF: neue Seite %d", doc.getPages().getCount());
        }

        /**
         * Prüft ob noch mindestens h Punkte bis zum unteren Rand verbleiben.
         * Wenn nicht → newPage(). Muss vor jedem cs.showText() / cs.drawImage() gerufen werden.
         */
        void ensureSpace(float h) throws Exception {
            if (y - h < MARGIN) newPage();
        }

        /**
         * Fügt n Punkte vertikalen Abstand ein.
         * Passt nicht mehr auf die Seite → newPage() (Abstand wird nicht auf die neue Seite
         * übertragen, damit Überschriften nicht mit riesigem Leerraum oben erscheinen).
         */
        void skip(float n) throws Exception {
            if (y - n < MARGIN) newPage();
            else y -= n;
        }

        // ── Emoji → Java2D-Shape ──────────────────────────────────────────────
        /**
         * Erzeugt ein PDImageXObject für eines der fünf Report-Emoji.
         *
         * Hintergrund: Java AWT headless kann NotoColorEmoji (COLR v1-Format) nicht
         * rendern — drawString() erzeugt ein transparentes Bild. Lösung: jedes Icon
         * wird per Java2D-Shapes direkt in ein BufferedImage gezeichnet.
         *
         * Pixel-Größe = max(24, sizePt * 3) für ausreichende Auflösung bei Antialiasing.
         * Das Ergebnis wird via LosslessFactory (PNG-Kompression) in das PDF eingebettet.
         */
        PDImageXObject emojiImage(String emoji, float sizePt) throws Exception {
            int px  = Math.max(24, (int)(sizePt * 3));
            int pad = Math.max(1, px / 16);
            String cacheKey = emoji.codePointAt(0) + "@" + px;

            // Level 1: per-document PDImageXObject cache (PDDocument-gebunden)
            PDImageXObject cached = emojiCache.get(cacheKey);
            if (cached != null) return cached;

            long tEmoji = System.currentTimeMillis();
            emojiCacheMisses++;

            // Level 2: JVM-weiter BufferedImage-Cache (überlebt mehrere Dokumente)
            // Lambda darf keine checked Exceptions werfen → alles per Java2D-Shapes, kein AWT-Font
            int cp = emoji.isEmpty() ? 0 : emoji.codePointAt(0);
            BufferedImage img = BUFFERED_IMAGE_CACHE.computeIfAbsent(cacheKey, k -> {
                BufferedImage bi = new BufferedImage(px + pad * 2, px + pad * 2,
                                                     BufferedImage.TYPE_INT_ARGB);
                Graphics2D g = bi.createGraphics();
                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
                g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);

                float cx = pad + px / 2f;
                float cy = pad + px / 2f;
                float r  = px / 2f - 0.5f;

                switch (cp) {
                    case 0x2705 -> { // ✅  grüner Kreis + weißes Häkchen
                        g.setColor(new Color(52, 168, 83));
                        g.fill(new Ellipse2D.Float(pad, pad, px, px));
                        g.setColor(Color.WHITE);
                        g.setStroke(new BasicStroke(px / 6f,
                                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
                        g.drawPolyline(
                            new int[]{(int)(cx - r*.42f), (int)(cx - r*.08f), (int)(cx + r*.42f)},
                            new int[]{(int)(cy + r*.05f), (int)(cy + r*.42f), (int)(cy - r*.32f)},
                            3);
                    }
                    case 0x274C -> { // ❌  roter Kreis + weißes X
                        g.setColor(new Color(234, 67, 53));
                        g.fill(new Ellipse2D.Float(pad, pad, px, px));
                        g.setColor(Color.WHITE);
                        g.setStroke(new BasicStroke(px / 5.5f,
                                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
                        float d = r * 0.34f;
                        g.drawLine((int)(cx-d),(int)(cy-d),(int)(cx+d),(int)(cy+d));
                        g.drawLine((int)(cx+d),(int)(cy-d),(int)(cx-d),(int)(cy+d));
                    }
                    case 0x26A0 -> { // ⚠️  gelbes Dreieck + ! (Shape, kein AWT-Font)
                        g.setColor(new Color(251, 188, 4));
                        g.fillPolygon(
                            new int[]{(int)cx, pad + px, pad},
                            new int[]{pad,     pad + px,  pad + px}, 3);
                        g.setColor(new Color(50, 50, 50));
                        // "!" als Shape: Balken (fillRect) + Punkt (Ellipse)
                        float barW = Math.max(2f, px * 0.10f);
                        float barH = px * 0.32f;
                        float dotD = Math.max(2f, px * 0.11f);
                        g.fill(new java.awt.geom.RoundRectangle2D.Float(
                                cx - barW / 2f, pad + px * 0.37f, barW, barH, barW / 2, barW / 2));
                        g.fill(new Ellipse2D.Float(
                                cx - dotD / 2f, pad + px * 0.77f, dotD, dotD));
                    }
                    case 0x2753 -> { // ❓  roter Kreis + weißes ? (Shape, kein AWT-Font)
                        g.setColor(new Color(234, 67, 53));
                        g.fill(new Ellipse2D.Float(pad, pad, px, px));
                        g.setColor(Color.WHITE);
                        float sw = Math.max(1.5f, px / 7.5f);
                        g.setStroke(new BasicStroke(sw, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
                        // "?" Haken via quadratische Bézierkurven
                        float x0 = pad, y0 = pad;
                        java.awt.geom.Path2D.Float path = new java.awt.geom.Path2D.Float();
                        path.moveTo(x0 + px * 0.28f, y0 + px * 0.26f); // linkes offenes Ende
                        path.quadTo(x0 + px * 0.26f, y0 + px * 0.12f,
                                    x0 + px * 0.50f, y0 + px * 0.12f); // nach oben-mitte
                        path.quadTo(x0 + px * 0.74f, y0 + px * 0.12f,
                                    x0 + px * 0.74f, y0 + px * 0.30f); // über die Spitze rechts
                        path.quadTo(x0 + px * 0.74f, y0 + px * 0.46f,
                                    x0 + px * 0.56f, y0 + px * 0.50f); // rechte Seite einwärts
                        path.quadTo(x0 + px * 0.50f, y0 + px * 0.52f,
                                    x0 + px * 0.50f, y0 + px * 0.62f); // Schaft nach unten
                        g.draw(path);
                        float dotD2 = Math.max(2f, px * 0.10f);
                        g.fill(new Ellipse2D.Float(
                                x0 + px * 0.50f - dotD2 / 2f, y0 + px * 0.78f, dotD2, dotD2));
                    }
                    case 0x1F4C4 -> { // 📄  blaues Dokument
                        int dw = (int)(px * .70f), dh = (int)(px * .90f);
                        int dx = (int)(cx - dw / 2f), dy = pad + (int)(px * .05f);
                        int fold = (int)(px * .22f);
                        g.setColor(new Color(66, 133, 244));
                        g.fillPolygon(
                            new int[]{dx, dx + dw - fold, dx + dw, dx + dw, dx},
                            new int[]{dy, dy,              dy + fold, dy + dh, dy + dh}, 5);
                        g.setColor(new Color(30, 90, 200));
                        g.fillPolygon(
                            new int[]{dx + dw - fold, dx + dw - fold, dx + dw},
                            new int[]{dy,              dy + fold,       dy + fold}, 3);
                        g.setColor(Color.WHITE);
                        int lw = (int)(dw * .56f), lx = dx + (int)(dw * .18f);
                        int lh = Math.max(1, (int)(dh * .08f)), gap = (int)(dh * .13f);
                        int ly = dy + (int)(dh * .38f);
                        for (int l = 0; l < 3; l++) g.fillRect(lx, ly + l * gap, lw, lh);
                    }
                    default -> { // Unbekannter Codepoint: grauer Kreis (Fallback)
                        g.setColor(new Color(158, 158, 158));
                        g.fill(new Ellipse2D.Float(pad, pad, px, px));
                    }
                }
                g.dispose();
                return bi;
            });

            // BufferedImage → PDImageXObject (verlustfreie PNG-Einbettung)
            PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
            emojiCache.put(cacheKey, xobj);
            LOG.infof("PDF: emojiImage('%s' U+%04X, %dpx) neu gerendert in %dms",
                    cacheKey, emoji.codePointAt(0), px, System.currentTimeMillis() - tEmoji);
            return xobj;
        }

        /**
         * Filtert einen String auf PDType1Font-sichere Zeichen (Latin-1, 0x20–0xFF).
         * WinAnsiEncoding akzeptiert keine Zeichen außerhalb dieses Bereichs;
         * unbekannte Codepoints werden durch Leerzeichen ersetzt.
         */
        private String safe(String s) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < s.length(); ) {
                int cp = s.codePointAt(i);
                sb.append((cp >= 0x20 && cp <= 0xFF) ? (char) cp : ' ');
                i += Character.charCount(cp);
            }
            return sb.toString();
        }

        /** Gibt die passende PDType1Font-Instanz für einen Segment-Typ zurück. */
        private PDType1Font fontFor(SegType t) {
            return t == SegType.BOLD ? fBold : t == SegType.ITALIC ? fItalic : fNorm;
        }

        /**
         * Berechnet die Breite eines Segments in PDF-Punkten.
         * Emoji: geschätzt als 1,5 × Schriftgröße pro Codepoint.
         * Text: exakt über getStringWidth() der jeweiligen PDType1Font.
         */
        float segWidth(Seg seg, float size) throws Exception {
            if (seg.type() == SegType.EMOJI)
                return size * 1.5f * seg.text().codePointCount(0, seg.text().length());
            return fontFor(seg.type()).getStringWidth(safe(seg.text())) / 1000f * size;
        }

        /**
         * Rendert eine Liste von Segmenten inline (nebeneinander) ab x-Position startX
         * auf der Basislinie baseline.
         *
         * EMOJI-Segmente: emojiImage() → cs.drawImage(); vertikal zentriert zur Baseline.
         * PLAIN/BOLD/ITALIC: beginText / setFont / newLineAtOffset / showText / endText.
         *
         * Gibt die neue x-Position nach dem letzten Segment zurück.
         */
        float renderSegsAt(List<Seg> segs, float startX, float baseline, float size,
                           float emojiSize) throws Exception {
            float x = startX;
            for (Seg seg : segs) {
                if (seg.type() == SegType.EMOJI) {
                    PDImageXObject img = emojiImage(seg.text(), emojiSize);
                    float imgH = emojiSize;
                    float imgW = imgH * img.getWidth() / (float) img.getHeight();
                    // 0.40f: Bild leicht über die Baseline heben (vertikal zentriert zum Text)
                    cs.drawImage(img, x, baseline - imgH * 0.40f, imgW, imgH);
                    x += imgW + 1;
                } else {
                    String txt = safe(seg.text());
                    if (!txt.isEmpty()) {
                        PDType1Font f = fontFor(seg.type());
                        cs.beginText();
                        cs.setFont(f, size);
                        cs.newLineAtOffset(x, baseline);
                        cs.showText(txt);
                        cs.endText();
                        x += f.getStringWidth(txt) / 1000f * size;
                    }
                }
            }
            return x;
        }

        /**
         * Rendert eine Markdown-Zeile mit Zeilenumbruch.
         *
         * Zwei Pfade:
         *   a) Nur PLAIN-Segmente → writeWrappedText() (einfacher, schneller Pfad)
         *   b) Gemischt (Bold/Italic/Emoji) → splitWords() zerlegt in Wort-Einheiten,
         *      dann werden diese zu Zeilen gepackt und mit flushInlineLine() geschrieben.
         *
         * indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes).
         */
        void renderLine(String text, PDType1Font defaultFont, float size, float indent,
                        SegType[] state) throws Exception {
            List<Seg> segs = parseSegments(text, state);
            boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN);
            if (onlyPlain) {
                writeWrappedText(defaultFont, size, indent, text);
                return;
            }

            float emojiPt = size * 1.5f;
            float maxW    = TEXT_WIDTH - indent;

            List<List<Seg>> words = splitWords(segs);
            List<List<Seg>> line  = new ArrayList<>();
            float lineW = 0;

            for (List<Seg> word : words) {
                float ww = 0;
                for (Seg s : word) ww += segWidth(s, s.type() == SegType.EMOJI ? emojiPt : size);

                if (!line.isEmpty() && lineW + ww > maxW) {
                    flushInlineLine(line, size, emojiPt, indent);
                    line  = new ArrayList<>();
                    lineW = 0;
                    // Führendes Leerzeichen am Zeilenanfang überspringen
                    if (word.size() == 1 && word.get(0).type() == SegType.PLAIN
                            && word.get(0).text().isBlank()) continue;
                }
                line.add(word);
                lineW += ww;
            }
            if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent);
        }

        /** Überladung ohne Zustandsverfolgung (für Blockquotes, Tabellenzeilen). */
        void renderLine(String text, PDType1Font defaultFont, float size, float indent)
                throws Exception {
            renderLine(text, defaultFont, size, indent, new SegType[]{null});
        }

        /**
         * Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream.
         * Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe,
         * ruft ensureSpace() für den Seitenumbruch und delegiert an renderSegsAt().
         */
        private void flushInlineLine(List<List<Seg>> words, float size, float emojiPt,
                                     float indent) throws Exception {
            float lineH = Math.max(LINE_HEIGHT, emojiPt * 1.05f);
            ensureSpace(lineH);
            List<Seg> flat = new ArrayList<>();
            for (List<Seg> w : words) flat.addAll(w);
            renderSegsAt(flat, MARGIN + indent, y, size, emojiPt);
            y -= lineH;
        }

        /**
         * Zerlegt eine Liste von Segmenten in "Wörter" (für den Zeilenumbruch).
         *
         * Regeln:
         *   - EMOJI: jedes Emoji ist ein eigenes Wort (kein Umbruch innerhalb)
         *   - PLAIN/BOLD/ITALIC: an Leerzeichen aufteilen; jedes Leerzeichen
         *     wird als eigenes PLAIN-" "-Wort eingefügt (Trennstelle beim Umbrechen)
         */
        private List<List<Seg>> splitWords(List<Seg> segs) {
            List<List<Seg>> words = new ArrayList<>();
            List<Seg> cur = new ArrayList<>();
            for (Seg seg : segs) {
                if (seg.type() == SegType.EMOJI) {
                    if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
                    words.add(List.of(seg));
                    continue;
                }
                String txt = seg.text();
                int start = 0;
                for (int i = 0; i <= txt.length(); i++) {
                    if (i == txt.length() || txt.charAt(i) == ' ') {
                        if (i > start)
                            cur.add(new Seg(seg.type(), txt.substring(start, i)));
                        if (i < txt.length()) {
                            if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
                            words.add(List.of(new Seg(SegType.PLAIN, " ")));
                        }
                        start = i + 1;
                    }
                }
                if (start < txt.length())
                    cur.add(new Seg(seg.type(), txt.substring(start)));
            }
            if (!cur.isEmpty()) words.add(cur);
            return words;
        }

        /**
         * Wortumbruch für reinen Text (keine Inline-Formatierung).
         * Misst jedes Wort mit getStringWidth(); bricht um wenn die Zeile voll ist.
         * Schreibt jede fertige Zeile via flushText().
         */
        void writeWrappedText(PDType1Font font, float size, float indent, String text)
                throws Exception {
            float maxW = TEXT_WIDTH - indent;
            String[] words = text.split(" ", -1);
            StringBuilder cur = new StringBuilder();
            for (String w : words) {
                String candidate = cur.isEmpty() ? w : cur + " " + w;
                float width;
                try { width = font.getStringWidth(safe(candidate)) / 1000f * size; }
                catch (Exception e) { width = maxW + 1; }
                if (width > maxW && !cur.isEmpty()) {
                    flushText(font, size, indent, cur.toString());
                    cur = new StringBuilder(w);
                } else {
                    cur = new StringBuilder(candidate);
                }
            }
            if (!cur.isEmpty()) flushText(font, size, indent, cur.toString());
        }

        /**
         * Schreibt eine einzelne Textzeile auf den PDF-Content-Stream.
         * Ruft ensureSpace() → ggf. newPage() vor dem Schreiben.
         * Dekrementiert y um LINE_HEIGHT danach.
         */
        void flushText(PDType1Font font, float size, float indent, String text) throws Exception {
            ensureSpace(LINE_HEIGHT);
            cs.beginText();
            cs.setFont(font, size);
            cs.newLineAtOffset(MARGIN + indent, y);
            cs.showText(safe(text));
            cs.endText();
            y -= LINE_HEIGHT;
        }

        /**
         * Rendert eine Tabelle ins PDF.
         * Spaltenbreite = TEXT_WIDTH / Anzahl Spalten (gleichmäßig verteilt).
         * Zellen werden inline via renderSegsAt() geschrieben — Emoji erscheinen als Icons.
         * Kein Rahmen (PDFBox-Rahmen wären aufwendiger und nicht nötig für Report-Tabellen).
         */
        void renderTable(List<String[]> rows) throws Exception {
            if (rows.isEmpty()) return;
            int maxCols = rows.stream().mapToInt(r -> r.length).max().orElse(1);
            float colW = TEXT_WIDTH / maxCols;
            float rowH = LINE_HEIGHT + 8;

            for (String[] row : rows) {
                ensureSpace(rowH);
                float cx = MARGIN;
                for (int c = 0; c < maxCols; c++) {
                    String cell = c < row.length ? row[c].trim() : "";
                    List<Seg> segs = parseSegments(cell);
                    float emojiPt = rowH * 0.85f;
                    renderSegsAt(segs, cx + 2, y, 10, emojiPt);
                    cx += colW;
                }
                y -= rowH;
            }
            skip(2);
        }

        // ── Hauptschleife ─────────────────────────────────────────────────────
        /**
         * Verarbeitet den Markdown-String Zeile für Zeile und baut das PDF auf.
         *
         * Dispatching:
         *   "#### " / "### " / "## " / "# "
         *       → skip() (Abstand davor) + renderLine() mit Bold + größerer Schrift
         *         + skip() (Abstand danach)
         *   "---"
         *       → skip(4) + horizontale Linie direkt auf Content-Stream (moveTo/lineTo/stroke)
         *         + y -= 6
         *   "> text"
         *       → renderLine() mit Italic-Font, 20pt Einzug
         *   "|..."
         *       → Zeilen in tableAccum sammeln; sobald keine Pipe-Zeile mehr kommt
         *         → renderTable(); Trenner-Zeilen (|---|) werden übersprungen
         *   Leerzeile
         *       → skip(4): 4pt vertikaler Abstand
         *   Sonstiger Text
         *       → renderLine() mit Normal-Font, 11pt
         */
        void write(String markdown) throws Exception {
            String[] lines = markdown.split("\n", -1);
            List<String[]> tableAccum = new ArrayList<>();
            int lineNr = 0;
            long tWrite = System.currentTimeMillis();
            SegType[] inlineState = {null}; // zeilenübergreifendes Bold/Italic-State

            for (String line : lines) {
                lineNr++;
                if (lineNr % 10 == 0) {
                    LOG.infof("PDF: write() Zeile %d/%d – %d Seiten – %dms bisher",
                            lineNr, lines.length, doc.getPages().getCount(),
                            System.currentTimeMillis() - tWrite);
                }
                if (lineNr >= lines.length - 20) {
                    LOG.infof("PDF: Zeile %d: [%s]", lineNr,
                            line.length() > 120 ? line.substring(0, 120) + "…" : line);
                }
                // Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt
                if (!line.startsWith("|") && !tableAccum.isEmpty()) {
                    renderTable(tableAccum);
                    tableAccum.clear();
                }

                if (line.startsWith("#### ")) {
                    inlineState[0] = null;
                    skip(10); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(4);
                } else if (line.startsWith("### ")) {
                    inlineState[0] = null;
                    skip(20); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(6);
                } else if (line.startsWith("## ")) {
                    inlineState[0] = null;
                    skip(28); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(8);
                } else if (line.startsWith("# ")) {
                    inlineState[0] = null;
                    skip(20); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(1);
                } else if (line.equals("---")) {
                    inlineState[0] = null;
                    skip(4);
                    ensureSpace(2);
                    cs.setLineWidth(0.5f);
                    cs.moveTo(MARGIN, y);
                    cs.lineTo(PAGE_WIDTH - MARGIN, y);
                    cs.stroke();
                    y -= 6;
                } else if (line.startsWith("> ")) {
                    inlineState[0] = null;
                    renderLine(line.substring(2).trim(), fItalic, 10, 20);
                } else if (line.startsWith("|")) {
                    inlineState[0] = null;
                    if (!isTableSeparator(line)) {
                        String[] parts = line.split("\\|", -1);
                        List<String> cells = new ArrayList<>();
                        for (int p = 1; p < parts.length - 1; p++)
                            cells.add(parts[p].trim());
                        if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0]));
                    }
                } else if (line.isBlank()) {
                    inlineState[0] = null; // Leerzeile = Absatzgrenze → Format zurücksetzen
                    skip(4);
                } else {
                    renderLine(line, fNorm, 11, 0, inlineState);
                }
            }
            if (!tableAccum.isEmpty()) {
                LOG.infof("PDF: renderTable() wird aufgerufen (%d Zeilen)", tableAccum.size());
                renderTable(tableAccum);
                LOG.infof("PDF: renderTable() fertig");
            }
            LOG.infof("PDF: cs.close() wird aufgerufen (Seite %d)", doc.getPages().getCount());
            cs.close();
            LOG.infof("PDF: cs.close() fertig");
            LOG.infof("PDF: write() fertig – %d Zeilen, %d Seiten, %dms",
                    lines.length, doc.getPages().getCount(),
                    System.currentTimeMillis() - tWrite);
        }
    }
}
