package tts

import (
	"encoding/json"
	"fmt"
	"os"
	"regexp"
	"sort"
	"strings"
)

// Pronunciation dictionary (Vikunja #458).
//
// piper reads a Russian sentence with a Russian voice, and a Latin service id
// inside that sentence comes out as letters or as noise: "Vikunja" is spelled
// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The
// fix is not a code change per name — it is a file of replacements applied to
// the text before piper sees it.
//
// Spelling, not phonemes. piper has no lexicon input of its own here, so the
// only lever is the text, and the entry for a name is how it should be spelled
// in Russian for the voice to say it right. That also means a wrong entry is
// visible: it is a word, and it is read out loud.
//
// The dictionary is data, so it ships as a file rather than a table in Go. A
// name added to it needs no rebuild and no deploy of the daemon that owns the
// text — only a restart of mavttsd, which is the process that reads it.

// Lexicon rewrites names into the spelling the voice reads correctly.
//
// The zero value is usable and rewrites nothing, so a daemon with no dictionary
// configured behaves exactly as it did before this existed.
type Lexicon struct {
	// Rules are held in one alternation rather than as a map, so a text is
	// scanned once however many entries there are, and the longest name wins
	// where two overlap ("Home Assistant" before "Home").
	re *regexp.Regexp
	// by lower-cased name, because the match is case-insensitive and the
	// replacement is not derived from what was matched.
	by map[string]string
}

// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling.
//
//	{"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"}
//
// An empty path returns an empty Lexicon and no error — the dictionary is off
// unless configured, like every other optional capability. A path that is set
// and unreadable IS an error: he asked for it, and silently saying names wrong
// is the failure this exists to remove.
func LoadLexicon(path string) (*Lexicon, error) {
	if strings.TrimSpace(path) == "" {
		return &Lexicon{}, nil
	}
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
	}
	var entries map[string]string
	if err := json.Unmarshal(raw, &entries); err != nil {
		return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
	}
	return NewLexicon(entries), nil
}

// NewLexicon builds a lexicon from entries already in memory.
func NewLexicon(entries map[string]string) *Lexicon {
	names := make([]string, 0, len(entries))
	by := make(map[string]string, len(entries))
	for name, say := range entries {
		name = strings.TrimSpace(name)
		if name == "" || strings.TrimSpace(say) == "" {
			continue
		}
		names = append(names, name)
		by[strings.ToLower(name)] = say
	}
	if len(names) == 0 {
		return &Lexicon{}
	}
	// Longest first: "Home Assistant" must match before "Home" does, and Go's
	// regexp alternation is leftmost-first, not longest-match.
	sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
	quoted := make([]string, len(names))
	for i, n := range names {
		quoted[i] = regexp.QuoteMeta(n)
	}
	// The boundaries are written out rather than left to \b, which is ASCII-only
	// and never fires next to a Cyrillic letter — so "в Vikunja," would not
	// match with \b on the left in a Russian sentence.
	pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])`
	return &Lexicon{re: regexp.MustCompile(pattern), by: by}
}

// Apply rewrites every name in the text. Text with no name in it comes back
// unchanged and untouched.
func (l *Lexicon) Apply(text string) string {
	if l == nil || l.re == nil || text == "" {
		return text
	}
	// Twice, because two names separated by a single space share the character
	// between them and one pass consumes it: "Nexus Praxis" would leave the
	// second name alone otherwise.
	out := l.replaceOnce(text)
	return l.replaceOnce(out)
}

func (l *Lexicon) replaceOnce(text string) string {
	return l.re.ReplaceAllStringFunc(text, func(m string) string {
		groups := l.re.FindStringSubmatch(m)
		if groups == nil {
			return m
		}
		say, ok := l.by[strings.ToLower(groups[2])]
		if !ok {
			return m
		}
		return groups[1] + say + groups[3]
	})
}

// Size reports how many names are loaded, for the startup log line.
func (l *Lexicon) Size() int {
	if l == nil {
		return 0
	}
	return len(l.by)
}
