package mg.dot.feufaro.pdf

import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.font.PDType0Font
import com.tom_roush.pdfbox.util.Matrix
import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.solfa.TimeUnitObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

private data class PdfColor(val r: Float, val g: Float, val b: Float)

private val COLOR_BLACK    = PdfColor(0f,           0f,           0f)
private val COLOR_SOPRANO  = PdfColor(180 / 255f,   0f,           0f)
private val COLOR_ALTO     = PdfColor(0f,           120 / 255f,   0f)
private val COLOR_TENOR    = PdfColor(0f,           0f,           180 / 255f)
private val COLOR_BASSE    = PdfColor(0f,           100 / 255f,   120 / 255f)

private fun PDPageContentStream.setNonStrokingPdfColor(c: PdfColor) =
    setNonStrokingColor(c.r, c.g, c.b)

// ── Sanitize ──────────────────────────────────────────────────────────────────
private fun String.sanitize(): String = this
    .replace('\u2015', '—')
    .replace('\u2014', '—')
    .replace('\u2013', '—')
    .replace('―', '-')
    .replace('\u00A0', ' ')
    .trim()
    .filter { it.code in 32..126 || it.code > 160 }

// ── Mesures texte ─────────────────────────────────────────────────────────────
private fun textWidth(font: PDType0Font, fontSize: Float, text: String): Float =
    try { font.getStringWidth(text.sanitize()) / 1000f * fontSize }
    catch (e: Exception) { fontSize * text.length * 0.6f }



private val REGEX_PREFIX_DIGITS2 = Regex("(\\d+)\\.")
private val REGEX_CLEAN          = Regex("^(\\d+\\.)+")
private val REGEX_HAS_LYRIC      = Regex("[a-zA-Z0-9]")
private val REGEX_CLEAN_GLOBAL   = Regex("\\d+\\.")

private fun cleanSyllable(raw: String): String =
    raw.replace(REGEX_CLEAN_GLOBAL, "").replace(Regex("\\s+"), " ").trim()

private fun prefixIsOnlyOne(rawSyl: String): Boolean {
    val digits = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1] }.toList()
    return digits.size == 1 && digits[0] == "1"
}

private fun measureNoteWidth(font: PDType0Font, fontSize: Float, text: String): Float {
    var total = 0f
    text.forEach { ch ->
        total += try { font.getStringWidth(ch.toString().sanitize()) / 1000f * fontSize }
        catch (e: Exception) { 0f }
    }
    return total
}

private fun bestColWidthPt(
    tuoList: List<TimeUnitObject>,
    font: PDType0Font,
    noteFontSize: Float,
    lyricFont:PDType0Font,
    lyricFontSize: Float
): Float {
    var maxNoteW = 0f
    var maxLyrW = 0f

    tuoList.forEach { tuo ->
        tuo.noteAsMultiString()
            .split("\n")
            .forEach { line ->
                val w = measureNoteWidth(font, noteFontSize, line)
                if (w > maxNoteW) {
                    maxNoteW = w
                }
            }
        //lyrics
        (1..2).forEach { index ->
            tuo.lyricsAsMultiString(index)
                .split("\n")
                .forEach { line ->
                    val trimmedLine = cleanSyllable(line)
                    val x = measureNoteWidth(font, lyricFontSize, trimmedLine)
                    if (x > maxLyrW) {
                        maxLyrW = x
                    }
                }
        }
    }
    val minRequiredByLyrics = maxLyrW * 0.65f

    return maxOf(maxNoteW + 2f, minRequiredByLyrics)
}

private fun spacedSyllable(
    currentSyl: String,
    allTemps: List<List<String>>,
    sIdx: Int,
    lineIdx: Int,
    colWidthPt: Float,
    font: PDType0Font,
    fontSize: Float
): Pair<String, Float> {
    val clean = currentSyl.sanitize()
    if (clean.isEmpty()) return "" to 0f

    val currentW = textWidth(font, fontSize, clean)

    // Si ça rentre, on ne décale pas (aligné à gauche sur la note)
    if (currentW <= colWidthPt) return clean to 0f

    val nextSyl = allTemps.getOrNull(sIdx + 1)?.getOrNull(lineIdx) ?: ""
    val prevSyl = if (sIdx > 0) allTemps.getOrNull(sIdx - 1)?.getOrNull(lineIdx) ?: "" else ""

    val nextEmpty = nextSyl.sanitize().trim().isEmpty()
    val prevEmpty = prevSyl.sanitize().trim().isEmpty()

    return when {
        // Si place libre à droite : on reste à gauche, on déborde à droite
        nextEmpty -> clean to 0f

        // Si place libre à gauche : on se colle à droite, on déborde à gauche
        prevEmpty -> clean to (colWidthPt - currentW)

        // Si bloqué ou libre des deux côtés : on centre pour équilibrer
        else -> clean to (colWidthPt - currentW) / 2f
    }
}

// ── getVirtualLineIndex ───────────────────────────────────────────────────────
fun getVirtualLineIndex(realLi: Int, spacings: List<Int>): Int {
    var sum = realLi
    for (i in 0 until realLi) sum += spacings[i]
    return sum
}

// ── drawHairPinForTUO ─────────────────────────────────────────────────────────
private fun drawHairPinForTUO(
    cs: PDPageContentStream,
    tuo: TimeUnitObject,
    gridColumnCount: Int,
    colX: Float, colWidth: Float,
    markerY: Float,
    noteLineH: Float
) {
    val hairPinSymbol = tuo.hasHairPin() ?: return

    if (hairPinSymbol != '=' && TimeUnitObject.lastHairPinSymbol == null) {
        TimeUnitObject.startHairPin(hairPinSymbol, tuo.numBlock)
        return
    }

    if (hairPinSymbol == '=' && TimeUnitObject.lastHairPinSymbol != null) {
        val hairPinStart      = TimeUnitObject.lastHairPinStart
        val lastHairPinSymbol = TimeUnitObject.lastHairPinSymbol!!

        val hairPinStartLine = (hairPinStart - 1) / gridColumnCount
        val hairPinEndLine   = (tuo.numBlock  - 1) / gridColumnCount
        if (hairPinStartLine != hairPinEndLine) { TimeUnitObject.endHairPin(); return }

        val colsDelta = (tuo.numBlock - hairPinStart).toFloat()
        val xStart = if (lastHairPinSymbol == '>') colX - colWidth * colsDelta else colX + colWidth / 2f
        val xEnd   = if (lastHairPinSymbol == '>') colX + colWidth / 2f        else colX - colWidth * colsDelta

        val markerMidY = markerY + noteLineH * 0.4f
        cs.setStrokingColor(0.2f, 0.2f, 0.2f); cs.setLineWidth(1f)

        cs.moveTo(xStart, markerMidY + noteLineH * 0.6f); cs.lineTo(xEnd, markerMidY); cs.stroke()
        cs.moveTo(xStart, markerMidY - noteLineH * 0.4f); cs.lineTo(xEnd, markerMidY); cs.stroke()

        TimeUnitObject.endHairPin()
    }
}

// ── drawTUOUnderlines ─────────────────────────────────────────────────────────
private fun drawTUOUnderlines(
    cs: PDPageContentStream,
    tuo: TimeUnitObject,
    font: PDType0Font,
    fontSize: Float,
    colX: Float,
    y: Float,
    noteLineH: Float,
    colWidth: Float,
    customSpacing: List<Int>
) {
    val multiLineText = tuo.noteAsMultiString()
    val noteLines     = multiLineText.split("\n")
    val separatorLength = if (tuo.sep0 in listOf(":", "!")) 1 else 0
    val leftMarginUnderline = when (tuo.sep0) {
        "!" -> 4f; ":" -> 3f; "|" -> 4f; "/" -> 4f; else -> 0f
    }

    tuo.annotations().forEach { ta ->
        val voiceLineIndex = ta.voiceNumber - 1
        val vLi       = getVirtualLineIndex(voiceLineIndex, customSpacing)
        val underlineY = y - (vLi * noteLineH) - 3f
        val lineText   = noteLines.getOrNull(voiceLineIndex) ?: return@forEach

        ta.underlineSpec.forEach { us ->
            var xStart = if (us.x > -1) colX + measureNoteWidth(font, fontSize, lineText.take(us.x)) else colX
            if (us.isNewParen) xStart += leftMarginUnderline

            val xEnd = if (us.y > -1) {
                val endIdx = (us.y + separatorLength + 1).coerceAtMost(lineText.length)
                colX + measureNoteWidth(font, fontSize, lineText.take(endIdx))
            } else colX + colWidth

            if (xEnd > xStart) {
                cs.setStrokingColor(0.3f, 0.3f, 0.3f); cs.setLineWidth(0.9f)
                cs.moveTo(xStart, underlineY); cs.lineTo(xEnd, underlineY); cs.stroke()
            }
        }
    }
}

// ── drawNoteWithModulation ────────────────────────────────────────────────────
private fun drawNoteWithModulation(
    cs: PDPageContentStream, font: PDType0Font, fontSize: Float,
    text: String, x: Float, y: Float
) {
    val parts = text.split(">")
    var curX  = x
    parts.forEachIndexed { index, part ->
        if (part.isEmpty()) return@forEachIndexed
        val isExposant  = index == 1
        val currentSize = if (isExposant) fontSize * 0.7f else fontSize
        val currentY    = if (isExposant) y + (fontSize * 0.45f) else y
        cs.beginText()
        cs.setTextMatrix(Matrix.getTranslateInstance(curX, currentY))
        cs.setFont(font, currentSize)
        cs.showText(part)
        cs.endText()
        curX += textWidth(font, currentSize, part) + 0.5f
    }
}

// ── drawNoteWithSubscript ─────────────────────────────────────────────────────
private fun drawNoteWithSubscript(
    cs: PDPageContentStream, font: PDType0Font, fontSize: Float,
    text: String, x: Float, y: Float
) {
    var curX = x
    var i    = 0
    while (i < text.length) {
        val str = text[i].toString()
        cs.beginText()
        cs.setFont(font, fontSize)
        cs.newLineAtOffset(curX, y)
        cs.showText(str.sanitize())
        cs.endText()
        curX += font.getStringWidth(str.sanitize()) / 1000f * fontSize
        i++
    }
}

// ── drawHeader ────────────────────────────────────────────────────────────────
private fun drawHeader(
    cs: PDPageContentStream,
    noteFont:PDType0Font,
    lyricFont:PDType0Font?,
    bolderFont: PDType0Font?,
    emmentalerfFont: PDType0Font?,
    pageWidth: Float, pageHeight: Float, marginX: Float, marginY: Float,
    usableWidth: Float,
    songTitle: String, songAuthor: String, songRhythm: String,
    songComposer: String, songKey: String, measure: String,
    stanza: Int, nbStanza: Int
) {
    val titleFont  = lyricFont ?: noteFont
    val normalFont = lyricFont ?: noteFont
    val smallFont  = noteFont
    val emmentalerFont = emmentalerfFont ?: noteFont
    val boldFont = bolderFont ?: noteFont

    fun tw(f: PDType0Font, sz: Float, t: String) =
        try { f.getStringWidth(t.sanitize()) / 1000f * sz } catch (e: Exception) { 0f }

    // ── Y calculés depuis marginY au lieu de valeurs fixes ────────────────────
    val titleY = pageHeight - marginY - 5f
    val line2Y = pageHeight - marginY - 21f
    val line3Y = pageHeight - marginY - 33f
    val sepY   = line3Y - 5f

    // Ligne 1 — Titre centré
    val titleSize = 15f
    val titleW = tw(titleFont, titleSize, songTitle)
    cs.beginText(); cs.setFont(titleFont, titleSize)
    cs.newLineAtOffset(marginX + (usableWidth - titleW) / 2f, titleY)
    cs.showText(songTitle.sanitize())
    cs.endText()

    // Ligne 2 — Author | Composer (droite)
    val sz2 = 9f
    cs.beginText(); cs.setFont(normalFont, sz2)
    cs.newLineAtOffset(marginX, line2Y); cs.showText(songAuthor.sanitize()); cs.endText()

    val composerW = tw(normalFont, sz2, songComposer)
    cs.beginText();
    cs.setFont(normalFont, sz2)
    cs.newLineAtOffset(marginX + usableWidth - composerW, line2Y)
    cs.showText(songComposer.sanitize());
    cs.endText()

    // Ligne 3 — Key | Measure | Rhythm
    val sz3 = 10f
    val keyTxt = "Dô dia "
    cs.beginText();
    cs.setFont(smallFont, sz3)
    cs.newLineAtOffset(marginX, line3Y);
    cs.showText(keyTxt);
    cs.endText()

    val keyW_2 = tw(smallFont, sz3, keyTxt)
    val keyTxt2 = "${songKey.sanitize()}"
    cs.beginText(); cs.setFont(boldFont, sz3+2f)
    cs.newLineAtOffset(marginX + keyW_2 + 3f, line3Y);
    cs.showText(keyTxt2);
    cs.endText()

    val keyW = tw(boldFont, sz3, keyTxt+keyTxt2) + 10f
    val sz32 = 14f
    val splitMeaRgx = """(\d+/\d+)\s+(.*)""".toRegex()
    val matchRes = splitMeaRgx.matchEntire(measure)
    var timeSignature = ""
    var titleSignature = ""
    if(matchRes != null) {
        timeSignature = matchRes.groupValues[1]
        titleSignature = matchRes.groupValues[2]
    }
    val timesig = timeSignature.toCharArray()

    var ksW = keyW
    timesig.forEach { c ->
        cs.beginText();
        if(c == '/') {
            cs.setFont(normalFont, sz32)
            cs.newLineAtOffset(marginX + ksW + 10f, line3Y)
            ksW += tw(normalFont, sz32, c.toString())
        } else {
            cs.setFont(emmentalerFont, sz32)
            cs.newLineAtOffset(marginX + ksW + 10f, line3Y)
            ksW += tw(emmentalerFont, sz32, c.toString())
        }
        cs.showText(c.toString());
        cs.endText()
    }
    cs.beginText();
    cs.setFont(smallFont, sz3)
    cs.newLineAtOffset(marginX + ksW + 15f, line3Y)
    cs.showText("${titleSignature}");
    cs.endText()

    val rhythmW = tw(normalFont, sz3, songRhythm.sanitize())
    cs.beginText();
    cs.setFont(normalFont, sz2)
    cs.newLineAtOffset(marginX + (usableWidth - rhythmW) / 2f, line3Y)
    cs.showText(songRhythm.sanitize());
    cs.endText()

    // Ligne de séparation
    cs.setStrokingColor(0.6f, 0.6f, 0.6f);
    cs.setLineWidth(0.4f)
    cs.moveTo(marginX, sepY);
    cs.lineTo(marginX + usableWidth, sepY);
    cs.stroke()
}

// ── actual fun ────────────────────────────────────────────────────────────────
actual fun rememberPdfExportAction(
    scope: CoroutineScope,
    fileRepository: FileRepository,
    gridData: GridTUOData,
    songTitle: String,
    measure: String,
    stanza: Int,
    nbStanza: Int,
    songKey: String,
    songAut: String,
    songComp: String,
    songRythm: String
): (PrintSettings) -> Unit {
    return { settings: PrintSettings ->
        scope.launch(Dispatchers.IO) {
            val (pdfBytes, computedFileName) = generatePdfToBytes(
                gridData, songTitle, measure, stanza,
                nbStanza, songKey, songAut, songComp, songRythm
            )
            val chosenPath = withContext(Dispatchers.Main) {
                fileRepository.pickSavePath(computedFileName)
            }
            if (chosenPath != null) {
                fileRepository.saveLocalFile(chosenPath, pdfBytes)
            }
        }
    }
}

// ── generatePdfToBytes ────────────────────────────────────────────────────────
private suspend fun generatePdfToBytes(
    gridData: GridTUOData,
    songTitle: String,
    measure: String,
    stanza: Int,
    nbStanza: Int,
    songKey: String,
    songAut: String,
    songComp: String,
    songRythm: String,
    settings: PrintSettings = defaultPrintSettings()
): Pair<ByteArray, String> {

    val tuoList = gridData.tuoList.drop(1)

    val REGEX_PREFIX_DIGITS = Regex("(\\d+)\\.")

    fun getVoicesFromPrefix(syl: String): List<Int> =
        REGEX_PREFIX_DIGITS.findAll(syl).map { it.groupValues[1].toInt() }.toList()

    val markerHeight   = settings.markerHeight
    val noteFontSize   = settings.noteFontSize
    val lyricFontSize  = settings.lyricFontSize
    val noteLineH      = settings.noteLineH
    val lyricLineH     = settings.lyricLineH
    val extraSylLineH  = settings.extraSylLineH
    val adjustedMargin = settings.adjustedMargin
    val interRowGap    = settings.interRowGap
    val marginX        = settings.marginX
    val marginY        = settings.marginY
    val headerH        = settings.headerH
    val pageWidth      = settings.pageWidth
    val pageHeight     = settings.pageHeight
    val usableWidth   = pageWidth - 2 * marginX
    val customNbGrid   = settings.nbGridOnMeasure

    // ── Polices ────────────────────────────────────────────────────────────────
    val noteFontBytes            = Res.readBytes("files/PTSerif-Regular.ttf")
    val lyricFontBytes           = Res.readBytes("files/LinLibertine_R.ttf")
    val markerItalicBoldFontBytes = Res.readBytes("files/PT Serif Bold Italic.ttf")
    val markerBoldFontBytes      = Res.readBytes("files/PTSerif-Bold.ttf")
    val emmentalerFontBytes      = Res.readBytes("files/emmentaler-20.ttf")

    PDDocument().use { doc ->

        val customFont      = PDType0Font.load(doc, ByteArrayInputStream(noteFontBytes), false)
        val lyricFont       = PDType0Font.load(doc, ByteArrayInputStream(lyricFontBytes), false)
        val markerItalicFont = PDType0Font.load(doc, ByteArrayInputStream(markerItalicBoldFontBytes), false)
        val markerBoldFont  = PDType0Font.load(doc, ByteArrayInputStream(markerBoldFontBytes), false)
        val markerEmmetFont = PDType0Font.load(doc, ByteArrayInputStream(emmentalerFontBytes), false)

        // ── Largeur optimale de colonne ────────────────────────────────────────
        val bestColW  = bestColWidthPt(tuoList, customFont, noteFontSize, lyricFont, lyricFontSize)
        val colCount = if (customNbGrid == 0) {
            ((usableWidth / bestColW).toInt()).coerceAtLeast(1)
        } else {
            customNbGrid
        }
        val colWidth = usableWidth / colCount

        val maxSylsSize = tuoList.maxOfOrNull { tuo ->
            (1..nbStanza).maxOfOrNull { sz -> tuo.getSingleSyllable(sz).size } ?: 1
        } ?: 1

        // ── Fonctions de dessin locales ────────────────────────────────────────
        fun newPage(): PDPageContentStream {
            val page = PDPage(settings.pdRectangle)
            doc.addPage(page)
            return PDPageContentStream(doc, page)
        }

        fun drawCurlyBrace(cs: PDPageContentStream, x: Float, top: Float, bottom: Float) {
            val height = top - bottom
            val braceW = (height * 0.12f).coerceIn(8f, 22f)
            cs.saveGraphicsState()
            cs.transform(Matrix(braceW / 10f, 0f, 0f, -(height / 90f), x - braceW, top))
            cs.setNonStrokingColor(0f, 0f, 0f)
            cs.moveTo(2.5255237f, 42.511266f)
            cs.curveTo(2.9018235f, 41.543703f, 2.9183988f, 40.479268f, 2.9295801f, 39.441167f)
            cs.curveTo(3.0257633f, 30.51126f,  3.0823959f, 21.580072f, 2.947325f,  12.650669f)
            cs.curveTo(2.9231886f, 11.055039f, 2.8933167f, 9.4523308f, 3.1035398f, 7.8704257f)
            cs.curveTo(3.3137629f, 6.2885207f, 3.7758163f, 4.7150177f, 4.6625942f, 3.3882765f)
            cs.curveTo(5.8680949f, 1.5846823f, 7.8548731f, 0.32344155f, 10f, 0f)
            cs.curveTo(9.1651831f, 0.77722338f, 8.4802709f, 1.7148791f, 7.9937845f, 2.746541f)
            cs.curveTo(6.9576584f, 4.9437899f, 6.8533308f, 7.4514513f, 6.8235522f, 9.8805609f)
            cs.curveTo(6.7206706f, 18.272857f, 7.2905092f, 26.672179f, 6.8823909f, 35.055177f)
            cs.curveTo(6.8167718f, 36.403033f, 6.7250316f, 37.755886f, 6.4343209f, 39.073653f)
            cs.curveTo(6.1436102f, 40.39142f,  5.6454801f, 41.680731f, 4.8313656f, 42.756947f)
            cs.curveTo(4.0971435f, 43.727549f, 3.1128448f, 44.507326f, 2f,         45f)
            cs.curveTo(3.2050792f, 45.603993f, 4.2555169f, 46.513477f, 5.0257355f, 47.619726f)
            cs.curveTo(5.9928965f, 49.008695f, 6.5043972f, 50.673120f, 6.7431543f, 52.348661f)
            cs.curveTo(6.9819115f, 54.024202f, 6.9612618f, 55.724436f, 6.9478472f, 57.416849f)
            cs.curveTo(6.8820492f, 65.718083f, 7.0018422f, 74.020174f, 6.9072398f, 82.321127f)
            cs.curveTo(6.8879888f, 84.004806f, 6.8714598f, 85.749672f, 7.5929830f, 87.289708f)
            cs.curveTo(8.0528414f, 88.422141f, 8.9242492f, 89.387018f, 10f,        90f)
            cs.curveTo(8.1813551f, 89.702562f, 6.4820251f, 88.725349f, 5.3102118f, 87.3031f)
            cs.curveTo(4.2259102f, 85.987066f, 3.606374f,  84.337657f, 3.2912749f, 82.661838f)
            cs.curveTo(2.9761757f, 80.986019f, 2.9488582f, 79.270938f, 2.9359838f, 77.565801f)
            cs.curveTo(2.869984f,  68.824508f, 3.1582519f, 60.082204f, 3.0067424f, 51.341975f)
            cs.curveTo(2.9840763f, 50.034421f, 2.9431715f, 48.687654f, 2.4144109f, 47.491567f)
            cs.curveTo(1.9369295f, 46.411476f, 1.0645415f, 45.51121f,  0f,         45f)
            cs.curveTo(1.1412417f, 44.575325f, 2.0841488f, 43.646153f, 2.5255237f, 42.511266f)
            cs.closePath(); cs.fill()
            cs.restoreGraphicsState()
        }

        fun drawFermata(cs: PDPageContentStream, x: Float, y: Float, scale: Float = 1f) {
            fun px(svgX: Float) = x + svgX * scale
            fun py(svgY: Float) = y + svgY * scale

            fun curveTo(
                cp1x: Float, cp1y: Float,
                cp2x: Float, cp2y: Float,
                ex: Float, ey: Float
            ) = cs.curveTo(px(cp1x), py(cp1y), px(cp2x), py(cp2y), px(ex), py(ey))

            fun drawEllipse(cx: Float, cy: Float, rx: Float, ry: Float) {
                val k = 0.5523f
                val left   = cx - rx;  val right  = cx + rx
                val bottom = cy - ry;  val top    = cy + ry

                cs.moveTo(px(right), py(cy))
                // quart haut-droit
                cs.curveTo(
                    px(right),       py(cy + ry * k),
                    px(cx + rx * k), py(top),
                    px(cx),          py(top)
                )
                // quart haut-gauche
                cs.curveTo(
                    px(cx - rx * k), py(top),
                    px(left),        py(cy + ry * k),
                    px(left),        py(cy)
                )
                // quart bas-gauche
                cs.curveTo(
                    px(left),        py(cy - ry * k),
                    px(cx - rx * k), py(bottom),
                    px(cx),          py(bottom)
                )
                // quart bas-droit
                cs.curveTo(
                    px(cx + rx * k), py(bottom),
                    px(right),       py(cy - ry * k),
                    px(right),       py(cy)
                )
                cs.closePath()
            }

            cs.setNonStrokingColor(0f, 0f, 0f)   // RGB noir
            cs.moveTo(px(0.3034f), py(0.6922f))

            // Dôme extérieur — côté gauche
            curveTo(2.6605f, 16.6051f,  13.8570f, 30.7498f,  28.0000f, 30.7498f)
            // Dôme extérieur — côté droit
            curveTo(42.1430f, 30.7498f, 53.3395f, 16.6051f,  55.6966f,  0.6922f)

            // Épaisseur bord droit
            curveTo(55.8145f,  0.1028f, 52.8680f,  0.1028f,  52.7502f,  0.6922f)

            // Dôme intérieur — côté droit
            curveTo(49.8037f, 11.8901f, 42.1430f, 22.4987f,  28.0000f, 22.4987f)
            // Dôme intérieur — côté gauche
            curveTo(13.8570f, 22.4987f,  6.1963f, 11.8901f,   3.2498f,  0.6922f)

            // Épaisseur bord gauche
            curveTo(3.1320f,  0.1028f,  0.1855f,  0.1028f,   0.3034f,  0.6922f)

            cs.closePath()
            cs.fill()

            drawEllipse(
                cx = 28.0000f,
                cy =  5.8492f,
                rx =  5.5983f,
                ry =  5.5990f
            )
            cs.fill()
        }

        // ── drawMarkers ────────────────────────────────────────────────────────
        // NOTE : pas de TrueTypeFont / emmentalerTTF ici sur Android.
        // La fermata (𝄐) est dessinée via drawFermata() vectoriel.
        fun drawMarkers(
            cs: PDPageContentStream,
            tuo: TimeUnitObject,
            colX: Float, colWidth: Float, y: Float,
            boldFont: PDType0Font, italicBoldFont: PDType0Font, emmentalerFont: PDType0Font,
            fontSize: Float
        ) {
            val markerText    = tuo.pTemplate.markerToString()
            val hairPinSymbol = tuo.hasHairPin()
            val isTriolet     = tuo.isTriolet()

            val textMarkerOffset = 6f
            val markerY = y - markerHeight / 2 + 4f + textMarkerOffset

            // Point d'orgue → drawFermata (pas de GeneralPath sur Android)
            if (markerText.contains("𝄐") || tuo.pTemplate.template.contains("𝄐")) {
                drawFermata(cs, colX + colWidth / 2 - 5f, markerY - 2f, 0.3f)
            }

            // Marqueur texte
            if (markerText.isNotBlank() && !markerText.contains("𝄐")) {
                val dsRegex = Regex("""D\.?S\.?""")
                val dcRegex = Regex("""D\.?C\.?""")
                cs.beginText()
                when {
                    markerText.matches(Regex("^\\s*(ppp|pp|p|mp|mf|f|ff|fff)\\s*$")) ->
                        cs.setFont(emmentalerFont, fontSize + 10f)
                    markerText.contains(dsRegex) || markerText.contains(dcRegex) ||
                            markerText.contains("$") || markerText.contains("Do dia", false) ->
                        cs.setFont(boldFont, fontSize)
                    else -> cs.setFont(italicBoldFont, fontSize)
                }
                cs.newLineAtOffset(colX + 2f, markerY)
                cs.showText(markerText)
                cs.endText()
            }

            // Triolet
            if (isTriolet) {
                val arcX = colX + colWidth * 0.125f
                val arcWidth  = colWidth * 0.75f
                val arcHeight = 7f
                cs.setLineWidth(1.2f)
                cs.moveTo(arcX, markerY + 2f)
                cs.curveTo(
                    arcX + arcWidth * 0.25f, markerY + arcHeight,
                    arcX + arcWidth * 0.75f, markerY + arcHeight,
                    arcX + arcWidth,          markerY + arcHeight
                )
                cs.stroke()
                cs.beginText()
                cs.setFont(boldFont, fontSize * 0.7f)
                cs.newLineAtOffset(arcX + arcWidth * 0.45f, markerY + arcHeight * 0.85f)
                cs.showText("3")
                cs.endText()
            }
        }

        // ── Génération des pages ───────────────────────────────────────────────
        var cs = newPage()
        var y  = pageHeight - marginY - headerH

        drawHeader(
            cs, customFont, lyricFont, markerBoldFont, markerEmmetFont,
            pageWidth, pageHeight, marginX, marginY, usableWidth,
            songTitle    = songTitle,
            songAuthor   = songAut,
            songRhythm   = songRythm,
            songComposer = songComp,
            songKey      = songKey,
            measure      = measure,
            stanza       = stanza,
            nbStanza     = nbStanza
        )

        val rows = tuoList.chunked(colCount)

        rows.forEachIndexed { rowIdx, rowTuos ->
            val maxNoteLines  = (rowTuos.maxOfOrNull { it.noteAsMultiString().split("\n").size } ?: 1) + 1
            val maxLyricLines = rowTuos.maxOfOrNull { it.getSingleSyllable(stanza).size } ?: 0

            val rowHasMarkers = rowTuos.any { tuo ->
                tuo.pTemplate.markerToString().isNotBlank() || tuo.isTriolet() || tuo.hasHairPin() != null
            }

            val noteBlockH      = maxNoteLines * noteLineH
            val nbStanzasToShow = if (maxSylsSize >= 2) 1 else if (nbStanza >= 2) 2 else 1
            val lyricBlockH     = maxLyricLines * lyricLineH * nbStanzasToShow
            val rowH            = (if (rowHasMarkers) markerHeight else 0f) + noteBlockH + lyricBlockH + 10f * nbStanzasToShow + 8f

            if (y - rowH < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY }

            val notesStartY = if (rowHasMarkers) y - markerHeight else y

            val pitchPattern = "\\b(d|di|r|ri|m|mi|f|fi|s|si|l|la|t|ta)\\b".toRegex()

            fun singingVoiceIndices(tuo: TimeUnitObject): List<Int> =
                tuo.noteAsMultiString().split("\n").mapIndexedNotNull { li, line ->
                    val clean = line.trim()
                    val sings = clean.isNotBlank()
                            && !clean.all { it == '-' || it.isWhitespace() }
                            && clean != "—" && clean != "–"
                            && pitchPattern.containsMatchIn(clean)
                    if (sings) li else null
                }

            val spacings = MutableList(4) { 0 }

            rowTuos.forEach { tuo ->
                val singing  = singingVoiceIndices(tuo)
                val rawSyls  = tuo.getSingleSyllable(stanza)
                val rawSyl0  = rawSyls.getOrNull(0) ?: ""
                val cleanSyl0 = rawSyl0.replace(REGEX_CLEAN, "").trim()
                val syl0HasRealText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) &&
                        !cleanSyl0.all { it == '―' || it == '-' || it == '—' }
                val syl0EstVraimentVide = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
                        cleanSyl0.all { it == '―' || it == '-' || it == '—' }
                val uneAutreVoixPrendLeSoprano = (1..4).any { vIdx ->
                    val s = rawSyls.getOrNull(vIdx) ?: ""
                    s.contains("1.") && REGEX_HAS_LYRIC.containsMatchIn(s.replace(REGEX_CLEAN, ""))
                }
                val aDuTexteAuNiveauSoprano = !syl0EstVraimentVide || uneAutreVoixPrendLeSoprano

                (1..4).forEach { vIdx ->
                    val rawSyl    = rawSyls.getOrNull(vIdx) ?: ""
                    val cleanContent = cleanSyllable(rawSyl)
                    val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanContent) &&
                            !cleanContent.all { it == '―' || it == '-' || it == '—' }

                    if (rawSyl.isNotBlank() && hasActualText) {
                        val prefixInts = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
                        if (prefixInts.isNotEmpty()) {
                            val hasTrioATB = prefixInts.size == 3 && prefixInts.containsAll(listOf(2, 3, 4))
                            val hasDuoSA   = prefixInts.size == 2 && prefixInts.containsAll(listOf(1, 2))
                            val hasDuoTB   = prefixInts.size == 2 && prefixInts.containsAll(listOf(3, 4))
                            val isUnisson  = prefixInts.containsAll(listOf(1, 2, 3, 4))

                            if (prefixInts.contains(2) && prefixInts.contains(3)) {
                                spacings[2] = 1; spacings[1] = 0
                            }
                            if (isUnisson) { /* rien */ }
                            else if (hasTrioATB) spacings[3] = 1
                            else if (hasDuoSA)   spacings[1] = 1
                            else if (hasDuoTB)   { if (syl0HasRealText) spacings[1] = 1; spacings[3] = 1 }
                            else {
                                prefixInts.forEach { voiceNum ->
                                    when (voiceNum) {
                                        1 -> if (syl0HasRealText) spacings[0] = 1
                                        2 -> if (!prefixInts.contains(1)) spacings[1] = 1
                                        3 -> if (!prefixInts.contains(2)) spacings[2] = 1
                                        4 -> if (!prefixInts.contains(3)) spacings[3] = 1
                                    }
                                }
                            }
                        }
                    }
                }
            }

            val customSpacings = spacings.toList()

            // ---- 1. Marqueurs ----
            if (rowHasMarkers) {
                rowTuos.forEachIndexed { colIdx, tuo ->
                    val x = marginX + colIdx * colWidth
                    // ⚠️ Pas de emmentalerTTF sur Android → signature sans ce param
                    drawMarkers(cs, tuo, x, colWidth, y, markerBoldFont, markerItalicFont, markerEmmetFont, noteFontSize)
                    drawHairPinForTUO(cs, tuo, colCount, x, colWidth, y, noteLineH)
                }
            }

            // ---- 2. Accolade & étiquettes SATB ----
            val braceTop         = notesStartY + noteLineH * 0.85f
            val lastVoiceIdx     = (maxNoteLines - 1).coerceIn(0, 3)
            val totalVirtualSlots = getVirtualLineIndex(lastVoiceIdx, customSpacings) + 1
            val braceBottom      = notesStartY - (totalVirtualSlots - 1) * noteLineH
            drawCurlyBrace(cs, marginX, braceTop, braceBottom)

            val lineBottom   = braceBottom
            val voiceLabels  = listOf("S", "A", "T", "B")
            val labelFont    = lyricFont
            val labelSize    = noteFontSize * 0.85f
            val braceW       = ((braceTop - braceBottom) * 0.12f).coerceIn(8f, 22f)
            val labelX       = marginX - braceW - 3f

            voiceLabels.take(maxNoteLines).forEachIndexed { li, label ->
                val vLi    = getVirtualLineIndex(li, customSpacings)
                val labelY = notesStartY - (vLi * noteLineH)
                // Couleur par voix (sans java.awt.Color)
                val labelColor = when (label) {
                    "S"  -> COLOR_SOPRANO
                    "A"  -> COLOR_ALTO
                    "T"  -> COLOR_TENOR
                    "B"  -> COLOR_BASSE
                    else -> COLOR_BLACK
                }
                cs.saveGraphicsState()
                cs.beginText()
                cs.setFont(labelFont, labelSize)
                cs.setNonStrokingPdfColor(labelColor)
                cs.newLineAtOffset(labelX - textWidth(labelFont, labelSize, label), labelY)
                cs.showText(label)
                cs.endText()
                cs.restoreGraphicsState()
            }

            // ---- 3. Notes ----
            val lineTop = notesStartY + noteLineH * 0.85f

            rowTuos.forEachIndexed { colIdx, tuo ->
                val x    = marginX + colIdx * colWidth
                val sepW = textWidth(customFont, noteFontSize, "|")
                val noteX = when (tuo.sep0) {
                    "/", "|" -> {
                        cs.setStrokingColor(0f, 0f, 0f); cs.setLineWidth(0.8f)
                        cs.moveTo(x + sepW * 0.2f, lineTop); cs.lineTo(x + sepW * 0.2f, lineBottom); cs.stroke()
                        if (tuo.sep0 == "/") {
                            cs.moveTo(x + sepW * 0.7f, lineTop); cs.lineTo(x + sepW * 0.7f, lineBottom); cs.stroke()
                        }
                        x + sepW + 2f
                    }
                    else -> x
                }

                val noteLines = tuo.noteAsMultiString().split("\n")
                noteLines.forEachIndexed { li, line ->
                    val vLi      = getVirtualLineIndex(li, customSpacings)
                    val currentY = notesStartY - (vLi * noteLineH)
                    if (line.isNotBlank()) {
                        if (line.contains(">")) drawNoteWithModulation(cs, customFont, noteFontSize, line, noteX, currentY)
                        else                    drawNoteWithSubscript(cs, customFont, noteFontSize, line, noteX, currentY)
                    }
                }

                drawTUOUnderlines(cs, tuo, customFont, noteFontSize, x, notesStartY, noteLineH, colWidth, customSpacings)
            }

            // ---- 4. Paroles ----
            val lastVoiceIndex   = (maxNoteLines - 1).coerceIn(0, 3)
            val lastVirtualIndex = getVirtualLineIndex(lastVoiceIndex, customSpacings)
            val totalAreaSlots   = lastVirtualIndex + customSpacings[lastVoiceIndex]
            val finalBraceBottom = notesStartY - (totalAreaSlots * noteLineH)

            val stanzasToShow = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1)
            var lowestYOfThisRow = finalBraceBottom

            stanzasToShow.forEachIndexed { stanzaIdx, currentStanza ->
                val nbOverrideLines = customSpacings.sum()
                val yLyric          = finalBraceBottom - adjustedMargin - (nbOverrideLines * noteLineH * 0.2f)
                val yLyricOffset    = stanzaIdx * (lyricLineH * 1.1f)
                val currentYLyric   = yLyric - yLyricOffset

                val allTemps: List<List<String>> = rowTuos.map { it.getSingleSyllable(currentStanza) }
                val maxLinesThisStanza = allTemps.maxOfOrNull { it.size } ?: 1

                // Numéro de stanza
                val stanzaNumTxt  = "$currentStanza."
                val stanzaNumSize = lyricFontSize * 0.85f
                val stanzaNumW    = try { lyricFont.getStringWidth(stanzaNumTxt) / 1000f * stanzaNumSize } catch (e: Exception) { 0f }
                cs.beginText(); cs.setFont(lyricFont, stanzaNumSize)
                cs.setNonStrokingColor(0f, 0f, 0f)   // ← remplace setNonStrokingColor(Color.BLACK)
                cs.newLineAtOffset(marginX - stanzaNumW - 4f, currentYLyric)
                cs.showText(stanzaNumTxt); cs.endText()

                // ── drawInOverrideSlot (closure locale) ───────────────────────
                fun drawInOverrideSlot(
                    cs: PDPageContentStream,
                    targetIdx: Int,
                    cleanSyl: String,
                    sIdx: Int,
                    lineIdx: Int,
                    allTemps: List<List<String>>,
                    noteX: Float,
                    colWidth: Float,
                    customFont: PDType0Font,
                    lyricFontSize: Float,
                    notesStartY: Float,
                    noteLineH: Float,
                    customSpacings: List<Int>,
                    isDirectY: Boolean = false
                ) {
                    // Couleur par voix (sans java.awt.Color)
                    val lyricColor = when (targetIdx) {
                        -1, 0 -> COLOR_SOPRANO
                        1     -> COLOR_ALTO
                        2     -> COLOR_TENOR
                        3     -> COLOR_BASSE
                        else  -> COLOR_BLACK
                    }
                    val finalY = if (isDirectY) notesStartY
                    else {
                        val vLi = getVirtualLineIndex(targetIdx, customSpacings)
                        notesStartY - ((vLi + 1) * noteLineH) + 2f
                    }
                    val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
                    val lyricX = noteX + offsetX

                    cs.saveGraphicsState()
                    cs.beginText()
                    cs.setFont(customFont, lyricFontSize * 0.9f)
                    cs.setNonStrokingPdfColor(lyricColor)   // ← remplace setNonStrokingColor(lyricColor)
                    cs.newLineAtOffset(lyricX, finalY)
                    cs.showText(spacedSyl.sanitize())
                    cs.endText()
                    cs.restoreGraphicsState()
                }

                // Syllabes
                allTemps.forEachIndexed { sIdx, syllables ->
                    val x     = marginX + sIdx * colWidth
                    val noteX = if (rowTuos.getOrNull(sIdx)?.sep0 in listOf("/", "|")) x + 3f else x
                    val filledSlots          = mutableSetOf<Int>()
                    val isStandardLayout     = customSpacings.all { it == 0 }

                    syllables.forEachIndexed { lineIdx, rawSyl ->
                        if (lineIdx == 0) {
                            val (spacedSyl, offsetX) = spacedSyllable(rawSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
                            val lyricX = noteX + offsetX
                            cs.beginText()
                            cs.setFont(lyricFont, lyricFontSize)
                            cs.newLineAtOffset(lyricX, currentYLyric)
                            cs.showText(spacedSyl.sanitize())
                            cs.endText()
                        } else {
                            val isAllZeroSpacing = customSpacings.all { it == 0 }

                            // 1. D'abord, on vérifie si c'est un Solo Soprano "1." même si spacing est à 0
                            val prefixes = REGEX_PREFIX_DIGITS.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
                            val rawSyl0 = syllables.getOrNull(0) ?: ""
                            val cleanSyl0 = cleanSyllable(rawSyl0)
                            val cleanSyl = cleanSyllable(rawSyl)

                            val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
                                    cleanSyl0.all { it == '―' || it == '-' || it == '—' }

//                            val isAllZeroSpacing = customSpacings.all { it == 0 }

                            if (prefixes.contains(1) && isAllZeroSpacing && lignePrincipaleEstLibre) {
                                // FORCE LE DESSIN SUR LA LIGNE PRINCIPALE (SYL0)
                                val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
                                val lyricX = noteX + offsetX /*if (alignLeft) noteX else noteX + (colWidth / 2f) - (textWidth(customFont, lyricFontSize, spacedSyl) / 2f)*/

                                cs.beginText()
                                cs.setFont(if (lyricFont is PDType0Font) lyricFont else customFont, lyricFontSize)
                                cs.newLineAtOffset(lyricX, currentYLyric)
                                cs.showText(spacedSyl.sanitize())
                                cs.endText()

                            } else if (isAllZeroSpacing) {
                                val cleanSyl = cleanSyllable(rawSyl)
                                if (cleanSyl.isNotBlank()) {
                                    val thisLineY = currentYLyric - lineIdx * extraSylLineH
                                    val (spacedSyl, offsetX) = spacedSyllable(cleanSyl, allTemps, sIdx, lineIdx, colWidth, customFont, lyricFontSize)
                                    val lyricX = noteX + offsetX
                                    cs.beginText()
                                    cs.setFont(lyricFont, lyricFontSize)
                                    cs.setNonStrokingColor(0f, 0f, 0f)  // ← remplace Color.BLACK
                                    cs.newLineAtOffset(lyricX, thisLineY)
                                    cs.showText(spacedSyl.sanitize())
                                    cs.endText()
                                }
                            } else {
                                val prefixes  = REGEX_PREFIX_DIGITS2.findAll(rawSyl).map { it.groupValues[1].toInt() }.toList()
                                val cleanSyl  = cleanSyllable(rawSyl)
                                val rawSyl0   = syllables.getOrNull(0) ?: ""
                                val cleanSyl0 = cleanSyllable(rawSyl0)
                                val lignePrincipaleEstLibre = !REGEX_HAS_LYRIC.containsMatchIn(cleanSyl0) ||
                                        cleanSyl0.all { it == '―' || it == '-' || it == '—' }
                                val hasActualText = REGEX_HAS_LYRIC.containsMatchIn(cleanSyl) &&
                                        !cleanSyl.all { it == '―' || it == '-' }

                                if (prefixes.isNotEmpty() && hasActualText) {
                                    prefixes.forEach { voiceNum ->
                                        val targetIdx = voiceNum - 1
                                        if (voiceNum == 1) {
                                            if (lignePrincipaleEstLibre && !filledSlots.contains(-1)) {
                                                drawInOverrideSlot(cs, -1, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
                                                    customFont, lyricFontSize, currentYLyric, noteLineH, customSpacings, isDirectY = true)
                                                filledSlots.add(-1)
                                            } else if (customSpacings.getOrElse(0) { 0 } == 1 && !filledSlots.contains(0)) {
                                                drawInOverrideSlot(cs, 0, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
                                                    customFont, lyricFontSize, notesStartY - yLyricOffset, noteLineH, customSpacings)
                                                filledSlots.add(0)
                                            }
                                        } else {
                                            if (customSpacings.getOrElse(targetIdx) { 0 } == 1 && !filledSlots.contains(targetIdx)) {
                                                drawInOverrideSlot(cs, targetIdx, cleanSyl, sIdx, lineIdx, allTemps, noteX, colWidth,
                                                    customFont, lyricFontSize, notesStartY, noteLineH, customSpacings)
                                                filledSlots.add(targetIdx)
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                val isAllZeroSpacing = customSpacings.all { it == 0 }
                val maxExtraLines = if (isAllZeroSpacing) {
                    allTemps.maxOfOrNull { sylList ->
                        sylList.count { rawSyl ->
                            val clean = cleanSyllable(rawSyl)
                            REGEX_HAS_LYRIC.containsMatchIn(clean) && !clean.all { it == '―' || it == '-' || it == '—' }
                        }.coerceAtLeast(1)
                    } ?: 1
                } else 1

                val bottomOfCurrentStanza = currentYLyric - ((maxExtraLines - 1) * extraSylLineH) - 4f
                if (bottomOfCurrentStanza < lowestYOfThisRow) lowestYOfThisRow = bottomOfCurrentStanza
            }

            y = lowestYOfThisRow - interRowGap
        }

        // ── Stanzas restantes en texte ─────────────────────────────────────────
        val stanzasDisplayed = if (maxSylsSize >= 2) listOf(1) else if (nbStanza >= 2) listOf(1, 2) else listOf(1)
        val stanzasRemaining = (1..nbStanza).filter { it !in stanzasDisplayed }

        if (stanzasRemaining.isNotEmpty()) {
            val textFont   = lyricFont
            val textSize   = lyricFontSize
            val lineH      = lyricLineH
            val stanzaGapY = 12f

            y -= 7f
            if (y < marginY) { cs.close(); cs = newPage(); y = pageHeight - marginY }
            cs.setStrokingColor(0.5f, 0.5f, 0.5f); cs.setLineWidth(0.3f)
            cs.moveTo(marginX, y + 5f); cs.lineTo(marginX + usableWidth, y + 5f); cs.stroke()
            y -= 6f

            fun buildStanzaText(sz: Int): List<String> {
                val normalLyrics = mutableListOf<Pair<Int, String>>()
                val allDcLyrics  = mutableListOf<Pair<Int, String>>()

                tuoList.forEachIndexed { tuoIdx, tuo ->
                    val syls   = tuo.getSingleSyllable(sz)
                    val normal = syls.getOrNull(0) ?: ""
                    val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
                    if (normal.isNotEmpty()) normalLyrics.add(tuoIdx to normal)
                    if (dcPart.isNotEmpty()) allDcLyrics.add(tuoIdx to dcPart)
                }

                val result   = StringBuilder()
                var dcIndex  = 0
                var inDcBlock = false

                tuoList.forEachIndexed { tuoIdx, tuo ->
                    val syls   = tuo.getSingleSyllable(sz)
                    val normal = syls.getOrNull(0)?.trim() ?: ""
                    val dcPart = syls.drop(1).joinToString(" ") { it.trim() }
                    if (normal.isNotEmpty()) result.append(normal).append(" ")
                    if (dcPart.isNotEmpty()) result.append("[$dcPart] ")
                    val marker = tuo.pTemplate.markerToString()
                    if (marker.contains("DC") && !inDcBlock && allDcLyrics.isNotEmpty()) {
                        result.append("(")
                        while (dcIndex < allDcLyrics.size) {
                            val (dcIdx, dcText) = allDcLyrics[dcIndex]
                            if (dcIdx > tuoIdx) break
                            result.append(dcText).append(" "); dcIndex++
                        }
                        result.append(")"); inDcBlock = false
                    }
                }
                return listOf(result.toString().trim())
            }

            fun wrapLine(line: String, maxW: Float): List<String> {
                if (line.isBlank()) return listOf(line)
                val totalW = try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
                if (totalW <= maxW) return listOf(line)
                val parts   = mutableListOf<String>()
                val matcher = java.util.regex.Pattern.compile("\\S+\\s*").matcher(line)
                while (matcher.find()) parts.add(matcher.group())
                val result  = mutableListOf<String>()
                var current = ""
                parts.forEach { part ->
                    val candidate = current + part
                    val w = try { textFont.getStringWidth(candidate.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
                    if (w <= maxW || current.isEmpty()) current = candidate
                    else { result.add(current.replace(Regex("\\s+$"), "")); current = part }
                }
                if (current.isNotEmpty()) result.add(current)
                return result
            }

            fun naturalWidth(lines: List<String>): Float =
                lines.maxOfOrNull { line ->
                    try { textFont.getStringWidth(line.sanitize()) / 1000f * textSize } catch (e: Exception) { 0f }
                } ?: 0f

            fun stanzaHeight(wrappedLines: List<String>): Float = lineH + wrappedLines.size * lineH + stanzaGapY

            val availH        = y - marginY
            val allStanzaRaw  = stanzasRemaining.map { sz -> sz to buildStanzaText(sz) }
            val maxNatW       = allStanzaRaw.maxOfOrNull { (_, lines) -> naturalWidth(lines) } ?: (usableWidth / 2)

            val optimalCols: Int
            val colW: Float
            val innerW: Float

            when (stanzasRemaining.size) {
                1 -> { optimalCols = 1; colW = usableWidth; innerW = colW - 10f }
                2 -> { optimalCols = 2; colW = usableWidth / 2f; innerW = colW - 10f }
                else -> {
                    val best = (1..3).lastOrNull { nCols ->
                        val cWTest     = usableWidth / nCols
                        val innerWTest = cWTest - 10f
                        val wrapped    = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerWTest) } }
                        val colHeights = Array(nCols) { 0f }
                        wrapped.forEachIndexed { idx, (_, wLines) -> colHeights[idx % nCols] += stanzaHeight(wLines) }
                        colHeights.all { it <= availH }
                    } ?: 3
                    optimalCols = best; colW = usableWidth / optimalCols; innerW = colW - 10f
                }
            }

            val stanzaWrapped = allStanzaRaw.map { (sz, lines) -> sz to lines.flatMap { wrapLine(it, innerW) } }

            data class ColItem(val sz: Int, val lines: List<String>)
            val rows2 = stanzaWrapped.chunked(optimalCols)
            var rowStartY = y

            rows2.forEach { rowItems ->
                val rowH2 = rowItems.maxOfOrNull { (_, wLines) -> stanzaHeight(wLines) } ?: 0f
                if (rowStartY - rowH2 < marginY) { cs.close(); cs = newPage(); rowStartY = pageHeight - marginY }

                rowItems.forEachIndexed { colIdx, (sz, wrappedLines) ->
                    val colX = marginX + colIdx * colW
                    var colY = rowStartY
                    cs.beginText(); cs.setFont(textFont, textSize * 0.9f)
                    cs.newLineAtOffset(colX, colY); cs.showText("$sz."); cs.endText()
                    colY -= lineH
                    wrappedLines.forEach { line ->
                        cs.beginText(); cs.setFont(textFont, textSize)
                        cs.newLineAtOffset(colX + 10f, colY); cs.showText(line.sanitize()); cs.endText()
                        colY -= lineH
                    }
                }
                rowStartY -= (rowH2 + stanzaGapY)
            }
            y = rowStartY
        }

        cs.close()

        val cleanTitle        = songTitle.replace(" ", "_")
        val totalUsableHeight = pageHeight - (2 * marginY)
        val remainingHeight   = (y - marginY).coerceAtLeast(0f)
        val percentageUsed    = 100f - (remainingHeight / totalUsableHeight) * 100
        val percentageRemain  = 100f - percentageUsed
        val rStr = String.format("%.2f", percentageRemain).replace(",", ".")
        val uStr = String.format("%.2f", percentageUsed).replace(",",  ".")
        val computedFileName  = "${cleanTitle}_R_${rStr}_U_${uStr}.pdf"

        val outputStream = ByteArrayOutputStream()
        doc.save(outputStream)
        doc.close()

        return Pair(outputStream.toByteArray(), computedFileName)
    }
}