🏡 index : quine-global/quintodrome.git

package model

import (
	"bytes"
	"encoding/xml"
	"errors"
	"io"
	"math"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"unicode"

	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/utils/gg"
	"github.com/navidrome/navidrome/utils/str"
)

const (
	defaultTTMLFrameRate    = 30.0
	defaultTTMLSubFrameRate = 1.0
	defaultTTMLTickRate     = 1.0

	ttmlBackgroundAgentPrefix = "__nd_bg__|"
)

var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`)
var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`)

type ttmlTimeKind int

const (
	ttmlTimeAbsolute ttmlTimeKind = iota
	ttmlTimeOffset
	ttmlTimeAmbiguous
)

type ttmlTimingParams struct {
	frameRate    float64
	subFrameRate float64
	tickRate     float64
}

type ttmlTimingContext struct {
	lang     string
	role     string
	agentID  string
	begin    int64
	hasBegin bool
	end      int64
	hasEnd   bool
	invalid  bool
}

type ttmlLineRef struct {
	order int
	line  Line
}

type ttmlMetadataEntry struct {
	key  string
	line Line
	seq  int
}

type ttmlResolvedMetadataLine struct {
	order int
	seq   int
	line  Line
}

type ttmlDefinedAgent struct {
	ID   string
	Type string
	Name string
}

type ttmlPiece struct {
	raw     string
	cue     *Cue
	isBreak bool
}

type ttmlParser struct {
	decoder *xml.Decoder
	params  ttmlTimingParams

	mainLangOrder   []string
	mainLinesByLang map[string][]Line

	mainLineRefsByKey map[string]ttmlLineRef
	mainLineOrder     int

	translationLangOrder   []string
	translationEntriesByLg map[string][]ttmlMetadataEntry

	pronunciationLangOrder   []string
	pronunciationEntriesByLg map[string][]ttmlMetadataEntry

	definedAgents map[string]ttmlDefinedAgent

	metadataSeq int
}

func isTTMLDocument(contents []byte) bool {
	decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents)))
	for {
		token, err := decoder.Token()
		if err != nil {
			return false
		}
		if start, ok := token.(xml.StartElement); ok {
			return strings.EqualFold(start.Name.Local, "tt")
		}
	}
}

func parseTTML(defaultLang string, contents []byte) (LyricList, error) {
	contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`))

	// Skip non-TTML content so sniffing doesn't run the full TTML parse on plain
	// text — isTTMLDocument does a cheap decode that stops at the first element.
	// Checked after the encoding fixup so UTF-16-declared documents are recognized.
	if !isTTMLDocument(contents) {
		return nil, nil
	}

	p := ttmlParser{
		decoder: xml.NewDecoder(bytes.NewReader(contents)),
		params: ttmlTimingParams{
			frameRate:    defaultTTMLFrameRate,
			subFrameRate: defaultTTMLSubFrameRate,
			tickRate:     defaultTTMLTickRate,
		},
		mainLinesByLang:          make(map[string][]Line),
		mainLineRefsByKey:        make(map[string]ttmlLineRef),
		translationEntriesByLg:   make(map[string][]ttmlMetadataEntry),
		pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry),
		definedAgents:            make(map[string]ttmlDefinedAgent),
	}

	root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)}

	for {
		token, err := p.decoder.Token()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}

		start, ok := token.(xml.StartElement)
		if !ok {
			continue
		}

		if err := p.parseElement(start, root); err != nil {
			return nil, err
		}
	}

	return p.toLyricList(), nil
}

func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error {
	local := strings.ToLower(start.Name.Local)
	if local == "tt" {
		p.updateTimingParams(start.Attr)
	}

	switch local {
	case "translation":
		return p.parseMetadataTrack(start, parent, LyricKindTranslation)
	case "transliteration":
		return p.parseMetadataTrack(start, parent, LyricKindPronunciation)
	case "agent":
		return p.parseAgentDefinition(start)
	}

	ctx := p.childContext(start.Attr, parent)
	if local == "p" {
		lineText, tokens, err := p.parseParagraph(ctx)
		if err != nil {
			return err
		}
		if ctx.invalid || lineText == "" {
			return nil
		}

		parsedLine := Line{Value: lineText}
		if ctx.hasBegin {
			startMs := ctx.begin
			parsedLine.Start = &startMs
		}
		if ctx.hasEnd {
			endMs := ctx.end
			parsedLine.End = &endMs
		}
		if len(tokens) > 0 {
			parsedLine.Cue = tokens
		}
		parsedLine = normalizeLineTiming(parsedLine)

		lineKey, _ := attrValue(start.Attr, "key")
		p.addMainLine(ctx.lang, lineKey, parsedLine)
		return nil
	}

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return err
		}

		switch t := token.(type) {
		case xml.StartElement:
			nextParent := ctx
			if ctx.invalid {
				// Best effort: ignore invalid timing in container elements, and
				// continue traversing descendants with parent context.
				nextParent = parent
			}
			if err := p.parseElement(t, nextParent); err != nil {
				return err
			}
		case xml.EndElement:
			if strings.EqualFold(t.Name.Local, start.Name.Local) {
				return nil
			}
		}
	}
}

func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error {
	ctx := p.childContext(start.Attr, parent)
	lang := normalizeLyricLang(ctx.lang)

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return err
		}

		switch t := token.(type) {
		case xml.StartElement:
			if strings.EqualFold(t.Name.Local, "text") {
				entry, ok, err := p.parseMetadataText(t, ctx)
				if err != nil {
					return err
				}
				if ok {
					p.addMetadataEntry(kind, lang, entry)
				}
				continue
			}

			nextParent := ctx
			if ctx.invalid {
				nextParent = parent
			}
			if err := p.parseElement(t, nextParent); err != nil {
				return err
			}
		case xml.EndElement:
			if strings.EqualFold(t.Name.Local, start.Name.Local) {
				return nil
			}
		}
	}
}

func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error {
	id, ok := attrValue(start.Attr, "id")
	id = strings.TrimSpace(id)
	if !ok || id == "" {
		return p.skipElement(start)
	}

	agent := ttmlDefinedAgent{
		ID:   id,
		Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))),
	}

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return err
		}

		switch t := token.(type) {
		case xml.StartElement:
			if strings.EqualFold(t.Name.Local, "name") {
				name, err := p.collectElementText(t)
				if err != nil {
					return err
				}
				name = sanitizeTTMLText(name)
				if name != "" && agent.Name == "" {
					agent.Name = name
				}
				continue
			}
			if err := p.skipElement(t); err != nil {
				return err
			}
		case xml.EndElement:
			if strings.EqualFold(t.Name.Local, start.Name.Local) {
				p.definedAgents[agent.ID] = agent
				return nil
			}
		}
	}
}

func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) {
	forKey, hasFor := attrValue(start.Attr, "for")
	forKey = strings.TrimSpace(forKey)

	pieces, err := p.parseInlineElement(start, parent)
	if err != nil {
		return ttmlMetadataEntry{}, false, err
	}
	if !hasFor || forKey == "" {
		return ttmlMetadataEntry{}, false, nil
	}

	ctx := p.childContext(start.Attr, parent)
	if ctx.invalid {
		return ttmlMetadataEntry{}, false, nil
	}

	value, tokens := buildTTMLLineFromPieces(pieces)
	line := Line{Value: value}
	if ctx.hasBegin {
		startMs := ctx.begin
		line.Start = &startMs
	}
	if ctx.hasEnd {
		endMs := ctx.end
		line.End = &endMs
	}
	if len(tokens) > 0 {
		line.Cue = tokens
	}
	line = normalizeLineTiming(line)

	if line.Value == "" && len(line.Cue) == 0 {
		return ttmlMetadataEntry{}, false, nil
	}

	return ttmlMetadataEntry{key: forKey, line: line}, true, nil
}

func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) {
	var pieces []ttmlPiece

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return "", nil, err
		}

		switch t := token.(type) {
		case xml.StartElement:
			inlinePieces, err := p.parseInlineElement(t, parent)
			if err != nil {
				return "", nil, err
			}
			pieces = append(pieces, inlinePieces...)
		case xml.EndElement:
			if strings.EqualFold(t.Name.Local, "p") {
				value, tokens := buildTTMLLineFromPieces(pieces)
				return value, tokens, nil
			}
		case xml.CharData:
			pieces = append(pieces, ttmlPiece{raw: string(t)})
		}
	}
}

func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) {
	local := strings.ToLower(start.Name.Local)
	if local == "br" {
		return []ttmlPiece{{isBreak: true}}, nil
	}

	ctx := p.childContext(start.Attr, parent)
	_, hasBegin := attrValue(start.Attr, "begin")
	_, hasEnd := attrValue(start.Attr, "end")
	_, hasDur := attrValue(start.Attr, "dur")
	hasOwnTiming := hasBegin || hasEnd || hasDur

	var pieces []ttmlPiece

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return nil, err
		}

		switch t := token.(type) {
		case xml.StartElement:
			inlinePieces, err := p.parseInlineElement(t, ctx)
			if err != nil {
				return nil, err
			}
			pieces = append(pieces, inlinePieces...)
		case xml.EndElement:
			if !strings.EqualFold(t.Name.Local, start.Name.Local) {
				continue
			}

			if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) {
				rawValue := concatTTMLPieceRaw(pieces)
				tokenText := sanitizeTTMLText(rawValue)
				if tokenText != "" {
					parsedToken := Cue{
						AgentID: p.resolveCueAgentID(ctx),
					}
					if ctx.hasBegin {
						startMs := ctx.begin
						parsedToken.Start = &startMs
					}
					if ctx.hasEnd {
						endMs := ctx.end
						parsedToken.End = &endMs
					}

					return []ttmlPiece{{
						raw: rawValue,
						cue: &parsedToken,
					}}, nil
				}
			}

			return pieces, nil
		case xml.CharData:
			pieces = append(pieces, ttmlPiece{raw: string(t)})
		}
	}
}

func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
	finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces))
	for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 {
		finalized = finalized[1:]
	}
	for len(finalized) > 0 {
		last := finalized[len(finalized)-1]
		if last.text != "" || len(last.cues) > 0 {
			break
		}
		finalized = finalized[:len(finalized)-1]
	}

	var value strings.Builder
	cues := make([]Cue, 0, 8)
	byteOffset := 0
	for i, line := range finalized {
		if i > 0 {
			value.WriteByte('\n')
			byteOffset++
		}
		value.WriteString(line.text)
		for _, cue := range line.cues {
			cue.ByteStart += byteOffset
			cue.ByteEnd += byteOffset
			cues = append(cues, cue)
		}
		byteOffset += len(line.text)
	}

	return value.String(), cues
}

type ttmlFinalLine struct {
	text string
	cues []Cue
}

func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine {
	finalized := make([]ttmlFinalLine, 0, len(lines))
	for _, line := range lines {
		text, cues := finalizeTTMLLogicalLine(line)
		finalized = append(finalized, ttmlFinalLine{text: text, cues: cues})
	}
	return finalized
}

func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece {
	lines := [][]ttmlPiece{{}}
	prevEndedWithSpace := true // leading whitespace on a fresh line is dropped
	for _, piece := range pieces {
		if piece.isBreak {
			lines = append(lines, []ttmlPiece{})
			prevEndedWithSpace = true
			continue
		}

		raw := normalizeTTMLPieceRaw(piece.raw)
		// Collapse whitespace across piece boundaries: a piece's leading space is
		// redundant when the text emitted so far already ends with one.
		if prevEndedWithSpace {
			raw = strings.TrimPrefix(raw, " ")
		}
		if raw == "" {
			continue
		}
		lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
			raw: raw,
			cue: gg.Clone(piece.cue),
		})
		prevEndedWithSpace = strings.HasSuffix(raw, " ")
	}
	return lines
}

func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) {
	rawLine := concatTTMLPieceRaw(line)
	if rawLine == "" {
		return "", nil
	}

	leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace))
	rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace))
	trimmedEnd := len(rawLine) - rightTrimBytes
	if trimmedEnd < leftTrimBytes {
		trimmedEnd = leftTrimBytes
	}

	trimmed := strings.TrimSpace(rawLine)
	cues := make([]Cue, 0, len(line))
	cursor := 0
	for _, piece := range line {
		pieceEnd := cursor + len(piece.raw)
		if piece.cue != nil {
			byteStart := max(cursor, leftTrimBytes)
			byteEnd := min(pieceEnd, trimmedEnd)
			if byteStart < byteEnd {
				cue := *piece.cue
				cue.Value = rawLine[byteStart:byteEnd]
				cue.ByteStart = byteStart - leftTrimBytes
				cue.ByteEnd = byteEnd - leftTrimBytes - 1
				cues = append(cues, cue)
			}
		}
		cursor = pieceEnd
	}

	return trimmed, cues
}

// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode
// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that
// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so
// linefeeds and other whitespace runs collapse to a single space. Collapsing is
// applied unconditionally; xml:space="preserve" is not supported (no lyric
// source in practice relies on it). Hard line breaks come only from <br/>
// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed
// indentation between elements does not inject spurious newlines.
func normalizeTTMLPieceRaw(raw string) string {
	raw = str.SanitizeText(raw)
	return collapseTTMLWhitespace(raw)
}

func collapseTTMLWhitespace(raw string) string {
	var b strings.Builder
	b.Grow(len(raw))
	prevSpace := false
	for _, r := range raw {
		// Only the XML S production (space, tab, CR, LF) is collapsible whitespace.
		// Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not
		// whitespace, so they pass through unchanged.
		if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
			if !prevSpace {
				b.WriteByte(' ')
				prevSpace = true
			}
			continue
		}
		b.WriteRune(r)
		prevSpace = false
	}
	return b.String()
}

func concatTTMLPieceRaw(pieces []ttmlPiece) string {
	var raw strings.Builder
	for _, piece := range pieces {
		raw.WriteString(normalizeTTMLPieceRaw(piece.raw))
	}
	return raw.String()
}

func ttmlPiecesContainCue(pieces []ttmlPiece) bool {
	for _, piece := range pieces {
		if piece.cue != nil {
			return true
		}
	}
	return false
}

func (p *ttmlParser) toLyricList() LyricList {
	res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder))
	for _, lang := range p.mainLangOrder {
		lines := p.mainLinesByLang[lang]
		if len(lines) == 0 {
			continue
		}
		res = append(res, p.finalizeLyrics(Lyrics{
			Kind:   LyricKindMain,
			Lang:   lang,
			Line:   lines,
			Synced: linesAreSynced(lines),
		}))
	}

	res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...)
	res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...)
	return res
}

func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList {
	res := make(LyricList, 0, len(langOrder))

	for _, lang := range langOrder {
		entries := entriesByLang[lang]
		if len(entries) == 0 {
			continue
		}

		seenKeys := make(map[string]struct{}, len(entries))
		resolved := make([]ttmlResolvedMetadataLine, 0, len(entries))
		for _, entry := range entries {
			if _, exists := seenKeys[entry.key]; exists {
				continue
			}
			seenKeys[entry.key] = struct{}{}

			ref, ok := p.mainLineRefsByKey[entry.key]
			if !ok {
				log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key)
				continue
			}

			line := entry.line
			if line.Start == nil && ref.line.Start != nil {
				startMs := *ref.line.Start
				line.Start = &startMs
			}
			if line.End == nil && ref.line.End != nil {
				endMs := *ref.line.End
				line.End = &endMs
			}
			line = normalizeLineTiming(line)

			if line.Value == "" && len(line.Cue) == 0 {
				continue
			}

			resolved = append(resolved, ttmlResolvedMetadataLine{
				order: ref.order,
				seq:   entry.seq,
				line:  line,
			})
		}

		if len(resolved) == 0 {
			continue
		}

		sort.SliceStable(resolved, func(i, j int) bool {
			if resolved[i].order != resolved[j].order {
				return resolved[i].order < resolved[j].order
			}
			return resolved[i].seq < resolved[j].seq
		})

		lines := make([]Line, len(resolved))
		for i := range resolved {
			lines[i] = resolved[i].line
		}

		res = append(res, p.finalizeLyrics(Lyrics{
			Kind:   kind,
			Lang:   lang,
			Line:   lines,
			Synced: linesAreSynced(lines),
		}))
	}

	return res
}

func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics {
	lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line)
	return normalizeLyrics(lyrics)
}

func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) {
	if len(lines) == 0 {
		return lines, nil
	}

	usedOrder := make([]string, 0, 4)
	usedSet := make(map[string]struct{}, 4)
	sawEmptyCue := false

	for i := range lines {
		for j := range lines[i].Cue {
			agentID := strings.TrimSpace(lines[i].Cue[j].AgentID)
			if agentID == "" {
				sawEmptyCue = true
				continue
			}
			if _, exists := usedSet[agentID]; !exists {
				usedSet[agentID] = struct{}{}
				usedOrder = append(usedOrder, agentID)
			}
		}
	}

	if len(usedOrder) == 0 {
		return lines, nil
	}

	mainID := ""
	for _, agentID := range usedOrder {
		role := p.baseRoleForAgent(agentID)
		if role != "bg" && role != "group" {
			mainID = agentID
			break
		}
	}
	if mainID == "" && sawEmptyCue {
		mainID = "main"
	}
	if mainID == "" {
		for _, agentID := range usedOrder {
			if p.baseRoleForAgent(agentID) != "bg" {
				mainID = agentID
				break
			}
		}
	}
	if mainID == "" {
		mainID = usedOrder[0]
	}

	if _, exists := usedSet[mainID]; !exists {
		usedSet[mainID] = struct{}{}
		usedOrder = append([]string{mainID}, usedOrder...)
	}

	for i := range lines {
		for j := range lines[i].Cue {
			if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" {
				lines[i].Cue[j].AgentID = mainID
			}
		}
	}

	agents := make([]Agent, 0, len(usedOrder))
	for _, agentID := range usedOrder {
		role := p.baseRoleForAgent(agentID)
		if agentID == mainID {
			role = "main"
		}
		agent := Agent{
			ID:   agentID,
			Role: role,
			Name: p.agentNameForID(agentID),
		}
		agents = append(agents, agent)
	}

	return lines, agents
}

func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string {
	agentID := strings.TrimSpace(ctx.agentID)
	if contextHasRole(ctx.role, "x-bg") {
		if agentID == "" {
			agentID = "main"
		}
		return backgroundAgentID(agentID)
	}
	return agentID
}

func (p *ttmlParser) baseRoleForAgent(agentID string) string {
	if isBackgroundAgentID(agentID) {
		return "bg"
	}

	if agent, ok := p.definedAgents[agentID]; ok {
		switch agent.Type {
		case "group":
			return "group"
		default:
			return "voice"
		}
	}

	return "voice"
}

func (p *ttmlParser) agentNameForID(agentID string) string {
	if isBackgroundAgentID(agentID) {
		baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix)
		if baseID == "main" {
			return ""
		}
		if agent, ok := p.definedAgents[baseID]; ok {
			return agent.Name
		}
		return ""
	}

	if agent, ok := p.definedAgents[agentID]; ok {
		return agent.Name
	}

	return ""
}

func backgroundAgentID(agentID string) string {
	return ttmlBackgroundAgentPrefix + agentID
}

func isBackgroundAgentID(agentID string) bool {
	return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix)
}

func contextHasRole(roles string, role string) bool {
	lowerRole := strings.ToLower(role)
	for _, candidate := range strings.Fields(strings.ToLower(roles)) {
		if candidate == lowerRole {
			return true
		}
	}
	return false
}

func appendTTMLRoles(existing string, roles string) string {
	for _, role := range strings.Fields(roles) {
		if contextHasRole(existing, role) {
			continue
		}
		if existing == "" {
			existing = role
		} else {
			existing += " " + role
		}
	}
	return existing
}

func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) {
	lang = normalizeLyricLang(lang)
	if _, ok := p.mainLinesByLang[lang]; !ok {
		p.mainLangOrder = append(p.mainLangOrder, lang)
	}
	p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line)

	lineKey = strings.TrimSpace(lineKey)
	if lineKey != "" {
		if _, exists := p.mainLineRefsByKey[lineKey]; !exists {
			p.mainLineRefsByKey[lineKey] = ttmlLineRef{
				order: p.mainLineOrder,
				line:  line,
			}
		}
	}
	p.mainLineOrder++
}

func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) {
	lang = normalizeLyricLang(lang)
	entry.seq = p.metadataSeq
	p.metadataSeq++

	switch kind {
	case LyricKindTranslation:
		if _, ok := p.translationEntriesByLg[lang]; !ok {
			p.translationLangOrder = append(p.translationLangOrder, lang)
		}
		p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry)
	case LyricKindPronunciation:
		if _, ok := p.pronunciationEntriesByLg[lang]; !ok {
			p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang)
		}
		p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry)
	}
}

func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext {
	ctx := parent

	if lang, ok := attrValue(attrs, "lang"); ok {
		ctx.lang = normalizeLyricLang(lang)
	}
	if agentID, ok := attrValue(attrs, "agent"); ok {
		ctx.agentID = strings.TrimSpace(agentID)
	}
	if role, ok := attrValue(attrs, "role"); ok {
		role = strings.TrimSpace(role)
		if role != "" {
			ctx.role = appendTTMLRoles(ctx.role, role)
		}
	}

	beginExpr, hasBegin := attrValue(attrs, "begin")
	endExpr, hasEnd := attrValue(attrs, "end")
	durExpr, hasDur := attrValue(attrs, "dur")

	if hasBegin {
		begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params)
		if !ok {
			ctx.invalid = true
			return ctx
		}

		base := int64(0)
		if parent.hasBegin {
			base = parent.begin
		}
		ctx.begin = resolveTTMLTime(begin, kind, base, parent)
		ctx.hasBegin = true
	} else {
		ctx.begin = parent.begin
		ctx.hasBegin = parent.hasBegin
	}

	var calculatedEnd int64
	calculatedHasEnd := false

	if hasEnd {
		end, kind, ok := parseTTMLTimeExpression(endExpr, p.params)
		if !ok {
			ctx.invalid = true
			return ctx
		}

		base := ctx.begin
		if !ctx.hasBegin {
			base = parent.begin
		}
		calculatedEnd = resolveTTMLTime(end, kind, base, parent)
		calculatedHasEnd = true
	}

	if hasDur {
		dur, ok := parseTTMLDurationExpression(durExpr, p.params)
		if !ok {
			ctx.invalid = true
			return ctx
		}
		if ctx.hasBegin {
			durEnd := ctx.begin + dur
			if !calculatedHasEnd || durEnd < calculatedEnd {
				calculatedEnd = durEnd
				calculatedHasEnd = true
			}
		}
	}

	if !calculatedHasEnd && parent.hasEnd {
		calculatedEnd = parent.end
		calculatedHasEnd = true
	}

	ctx.end = calculatedEnd
	ctx.hasEnd = calculatedHasEnd
	return ctx
}

func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) {
	frameRate := p.params.frameRate
	if value, ok := attrValue(attrs, "frameRate"); ok {
		if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
			frameRate = parsed
		}
	}

	if value, ok := attrValue(attrs, "frameRateMultiplier"); ok {
		parts := strings.Fields(value)
		if len(parts) == 2 {
			numerator, errA := strconv.ParseFloat(parts[0], 64)
			denominator, errB := strconv.ParseFloat(parts[1], 64)
			if errA == nil && errB == nil && denominator > 0 {
				frameRate = frameRate * (numerator / denominator)
			}
		}
	}

	subFrameRate := p.params.subFrameRate
	if value, ok := attrValue(attrs, "subFrameRate"); ok {
		if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
			subFrameRate = parsed
		}
	}

	tickRate := p.params.tickRate
	if value, ok := attrValue(attrs, "tickRate"); ok {
		if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
			tickRate = parsed
		}
	}

	p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate)
	p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate)
	p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate)
}

func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) {
	value, _, ok := parseTTMLTimeExpression(expr, params)
	return value, ok
}

func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 {
	switch kind {
	case ttmlTimeAbsolute:
		return value
	case ttmlTimeOffset:
		return base + value
	case ttmlTimeAmbiguous:
		absolute := value
		offset := base + value

		// No parent timing context → no reference frame for offsets.
		// Prefer absolute when offset differs (i.e., base > 0).
		if !parent.hasBegin && !parent.hasEnd && base != 0 {
			return absolute
		}

		if parent.hasBegin && parent.hasEnd {
			absoluteInParent := absolute >= parent.begin && absolute <= parent.end
			offsetInParent := offset >= parent.begin && offset <= parent.end
			if absoluteInParent && !offsetInParent {
				return absolute
			}
			if offsetInParent && !absoluteInParent {
				return offset
			}
		}

		if parent.hasBegin {
			if absolute < parent.begin && offset >= parent.begin {
				return offset
			}
			if absolute >= parent.begin && offset > absolute {
				return absolute
			}
		}
		return offset
	default:
		return base + value
	}
}

func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) {
	expr = strings.TrimSpace(expr)
	if expr == "" {
		return 0, ttmlTimeOffset, false
	}

	lower := strings.ToLower(expr)
	if strings.Contains(lower, "wallclock(") ||
		strings.Contains(lower, ".begin") ||
		strings.Contains(lower, ".end") {
		log.Warn("Unsupported TTML time expression", "value", expr)
		return 0, ttmlTimeOffset, false
	}

	// Best-effort support for non-standard TTML seen in the wild where a
	// bare decimal value is used (implicitly seconds), e.g. "0.170".
	if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 {
		return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true
	}

	if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 {
		value, err := strconv.ParseFloat(matches[1], 64)
		if err != nil {
			return 0, ttmlTimeOffset, false
		}

		unit := matches[2]
		seconds := 0.0
		switch unit {
		case "h":
			seconds = value * 60 * 60
		case "m":
			seconds = value * 60
		case "s":
			seconds = value
		case "ms":
			seconds = value / 1000
		case "f":
			seconds = value / params.frameRate
		case "t":
			seconds = value / params.tickRate
		default:
			return 0, ttmlTimeOffset, false
		}

		return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true
	}

	colonCount := strings.Count(expr, ":")
	switch colonCount {
	case 1, 2:
		clockMs, ok := parseTTMLClockTime(expr)
		if !ok {
			return 0, ttmlTimeAbsolute, false
		}
		return clockMs, ttmlTimeAbsolute, true
	case 3:
		framesMs, ok := parseTTMLFrameTime(expr, params)
		if !ok {
			return 0, ttmlTimeAbsolute, false
		}
		return framesMs, ttmlTimeAbsolute, true
	default:
		log.Warn("Unsupported TTML time expression", "value", expr)
		return 0, ttmlTimeOffset, false
	}
}

func parseTTMLClockTime(value string) (int64, bool) {
	parts := strings.Split(value, ":")
	if len(parts) != 2 && len(parts) != 3 {
		return 0, false
	}

	hours := int64(0)
	minutesIdx := 0
	if len(parts) == 3 {
		h, err := strconv.ParseInt(parts[0], 10, 64)
		if err != nil {
			return 0, false
		}
		hours = h
		minutesIdx = 1
	}

	minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64)
	if err != nil {
		return 0, false
	}

	seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64)
	if err != nil {
		return 0, false
	}

	totalSeconds := float64(hours*60*60+minutes*60) + seconds
	return int64(math.Round(totalSeconds * 1000)), true
}

func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) {
	parts := strings.Split(value, ":")
	if len(parts) != 4 {
		return 0, false
	}

	hours, err := strconv.ParseInt(parts[0], 10, 64)
	if err != nil {
		return 0, false
	}

	minutes, err := strconv.ParseInt(parts[1], 10, 64)
	if err != nil {
		return 0, false
	}

	seconds, err := strconv.ParseInt(parts[2], 10, 64)
	if err != nil {
		return 0, false
	}

	frameParts := strings.SplitN(parts[3], ".", 2)
	frames, err := strconv.ParseFloat(frameParts[0], 64)
	if err != nil {
		return 0, false
	}

	subFrames := 0.0
	if len(frameParts) == 2 {
		subFrames, err = strconv.ParseFloat(frameParts[1], 64)
		if err != nil {
			return 0, false
		}
	}

	totalSeconds := float64(hours*60*60 + minutes*60 + seconds)
	totalSeconds += frames / params.frameRate
	totalSeconds += subFrames / (params.subFrameRate * params.frameRate)

	return int64(math.Round(totalSeconds * 1000)), true
}

func attrValue(attrs []xml.Attr, key string) (string, bool) {
	for _, attr := range attrs {
		if strings.EqualFold(attr.Name.Local, key) {
			return strings.TrimSpace(attr.Value), true
		}
	}
	return "", false
}

func attrOrEmpty(attrs []xml.Attr, key string) string {
	value, _ := attrValue(attrs, key)
	return value
}

func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) {
	var text strings.Builder

	for {
		token, err := p.decoder.Token()
		if err != nil {
			return "", err
		}

		switch t := token.(type) {
		case xml.StartElement:
			value, err := p.collectElementText(t)
			if err != nil {
				return "", err
			}
			text.WriteString(value)
		case xml.EndElement:
			if strings.EqualFold(t.Name.Local, start.Name.Local) {
				return text.String(), nil
			}
		case xml.CharData:
			text.WriteString(string(t))
		}
	}
}

func (p *ttmlParser) skipElement(_ xml.StartElement) error {
	depth := 1
	for depth > 0 {
		token, err := p.decoder.Token()
		if err != nil {
			return err
		}

		switch token.(type) {
		case xml.StartElement:
			depth++
		case xml.EndElement:
			depth--
		}
	}
	return nil
}

func normalizeLyricLang(lang string) string {
	lang = strings.ToLower(strings.TrimSpace(lang))
	if lang == "" {
		return "xxx"
	}
	return lang
}

func sanitizeTTMLText(raw string) string {
	raw = str.SanitizeText(raw)
	raw = strings.ReplaceAll(raw, "\r\n", "\n")
	raw = strings.ReplaceAll(raw, "\r", "\n")

	lines := strings.Split(raw, "\n")
	for i := range lines {
		lines[i] = strings.TrimSpace(lines[i])
	}
	return strings.TrimSpace(strings.Join(lines, "\n"))
}

func linesAreSynced(lines []Line) bool {
	for i := range lines {
		if lines[i].Start != nil {
			return true
		}
		for j := range lines[i].Cue {
			if lines[i].Cue[j].Start != nil {
				return true
			}
		}
	}
	return false
}