package main

import (
	"context"
	"encoding/json"
	"fmt"
	"image"
	"image/color"
	"image/draw"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode"
	"unicode/utf8"

	"github.com/disintegration/gift"
	"golang.org/x/image/font"
	"golang.org/x/image/font/gofont/goregular"
	"golang.org/x/image/font/opentype"
	"golang.org/x/image/math/fixed"

	"streamdeck-lets-go/internal/config"
)

type keyState struct {
	cancel context.CancelFunc
}

type DisplayOutput struct {
	Text       string `json:"text"`
	Background string `json:"background,omitempty"`
	TextColor  string `json:"text_color,omitempty"`
}

type PageManager struct {
	mu                  sync.RWMutex
	pages               map[string]*config.PageConfig
	active              string
	deck                *Deck
	keyStates           map[int]*keyState
	displayOutputs      map[int]*DisplayOutput
	displayMu           sync.RWMutex
	defaultFont         string
	showLabelBackground bool
	dynamicCancel       context.CancelFunc
	dynamicKeys         []config.KeyConfig
	dynamicKeysMu       sync.RWMutex
	dynamicResults      map[string][]config.KeyConfig
}

func NewPageManager(deck *Deck) *PageManager {
	return &PageManager{
		pages:          make(map[string]*config.PageConfig),
		deck:           deck,
		keyStates:      make(map[int]*keyState),
		displayOutputs: make(map[int]*DisplayOutput),
		defaultFont:    "medium",
		dynamicResults: make(map[string][]config.KeyConfig),
	}
}

func (pm *PageManager) activeKeys() []config.KeyConfig {
	pm.mu.RLock()
	page := pm.pages[pm.active]
	pm.mu.RUnlock()
	if page == nil {
		return nil
	}

	pm.dynamicKeysMu.RLock()
	dyn := pm.dynamicKeys
	pm.dynamicKeysMu.RUnlock()

	if dyn == nil {
		return page.Keys
	}

	used := make(map[int]bool, len(page.Keys))
	result := make([]config.KeyConfig, 0, len(page.Keys)+len(dyn))
	for _, k := range page.Keys {
		used[k.Index] = true
		result = append(result, k)
	}
	for _, k := range dyn {
		if !used[k.Index] {
			result = append(result, k)
		}
	}
	return result
}

// GetEffectiveKeys returns merged (static + cached dynamic) keys for a page.
// Used by the web UI to preview generated keys.
func (pm *PageManager) GetEffectiveKeys(name string) []config.KeyConfig {
	pm.mu.RLock()
	page := pm.pages[name]
	pm.mu.RUnlock()
	if page == nil {
		return nil
	}

	pm.dynamicKeysMu.RLock()
	cached := pm.dynamicResults[name]
	pm.dynamicKeysMu.RUnlock()

	if cached == nil {
		return page.Keys
	}

	used := make(map[int]bool, len(page.Keys))
	result := make([]config.KeyConfig, 0, len(page.Keys)+len(cached))
	for _, k := range page.Keys {
		used[k.Index] = true
		result = append(result, k)
	}
	for _, k := range cached {
		if !used[k.Index] {
			result = append(result, k)
		}
	}
	return result
}

func (pm *PageManager) LoadPages(pages []config.PageConfig) {
	pm.mu.Lock()
	pm.pages = make(map[string]*config.PageConfig, len(pages))
	for i := range pages {
		pm.pages[pages[i].Name] = &pages[i]
	}
	pm.mu.Unlock()

	pm.dynamicKeysMu.Lock()
	pm.dynamicResults = make(map[string][]config.KeyConfig)
	pm.dynamicKeysMu.Unlock()
}

func (pm *PageManager) ActivatePage(name string) error {
	pm.stopDynamicKeys()

	pm.mu.Lock()
	defer pm.mu.Unlock()

	page, ok := pm.pages[name]
	if !ok {
		return fmt.Errorf("page %q not found", name)
	}

	slog.Info("activating page", "name", name)
	pm.active = name

	pm.displayMu.Lock()
	pm.displayOutputs = make(map[int]*DisplayOutput)
	pm.displayMu.Unlock()

	if err := pm.deck.ClearAll(); err != nil {
		slog.Warn("clear all failed", "error", err)
	}

	if page.Background != "" {
		if img, err := loadImage(page.Background, 0, 0); err == nil {
			if err := pm.deck.FillPanel(img); err != nil {
				slog.Warn("fill panel failed", "error", err)
			}
		} else {
			slog.Warn("load background image", "path", page.Background, "error", err)
		}
	}

	keyByIndex := make(map[int]config.KeyConfig, len(page.Keys))
	for _, k := range page.Keys {
		keyByIndex[k.Index] = k
	}

	for i := 0; i < pm.deck.NumKeys(); i++ {
		k, ok := keyByIndex[i]
		if !ok {
			if page.Background == "" {
				pm.deck.ClearKey(i)
			}
			continue
		}
		pm.renderKey(i, &k)
	}

	if page.DynamicKeys != nil {
		go func() {
			pm.startDynamicKeys(page.DynamicKeys)
		}()
	}

	return nil
}

func (pm *PageManager) ActivePageName() string {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.active
}

func (pm *PageManager) ActivePage() *config.PageConfig {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.pages[pm.active]
}

func (pm *PageManager) GetDisplayOutputs() map[int]*DisplayOutput {
	pm.displayMu.RLock()
	defer pm.displayMu.RUnlock()
	result := make(map[int]*DisplayOutput, len(pm.displayOutputs))
	for k, v := range pm.displayOutputs {
		result[k] = v
	}
	return result
}

func (pm *PageManager) GetPage(name string) *config.PageConfig {
	pm.mu.RLock()
	defer pm.mu.RUnlock()
	return pm.pages[name]
}

func (pm *PageManager) RenderKey(keyIndex int) {
	for _, k := range pm.activeKeys() {
		if k.Index == keyIndex {
			pm.renderKey(keyIndex, &k)
			return
		}
	}
}

func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
	for _, k := range pm.activeKeys() {
		if k.Index == keyIndex && k.Display != nil {
			pm.renderKeyOutput(keyIndex, k.Display, output, nil)
			return
		}
	}
}

func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
	fontName := k.Font
	if fontName == "" {
		fontName = pm.defaultFont
	}
	fontSize := 18.0
	if k.FontSize != nil {
		fontSize = *k.FontSize
	}

	faScale := 0.55
	if k.IconScale != nil {
		faScale = *k.IconScale
	}

	if k.Icon != "" {
		img, err := loadImage(k.Icon, pm.deck.KeySize(), faScale)
		if err != nil {
			slog.Warn("load icon", "path", k.Icon, "error", err)
			pm.deck.FillColor(idx, 64, 64, 64)
			if k.Label != "" {
				pm.deck.WriteText(idx, k.Label, image.Black, fontName, fontSize)
			}
			return
		}

		if k.Background != "" {
			if bg, err := parseHexColor(k.Background); err == nil {
				img = applyBackground(img, bg)
			} else {
				slog.Warn("invalid background color", "value", k.Background, "error", err)
			}
		}

		onImgFontSize := fontSize
		if k.FontSize == nil {
			onImgFontSize = 16
		}
		onImgFontName := fontName
		if k.Font == "" {
			onImgFontName = pm.defaultFont
		}

		if k.Label != "" {
			if err := pm.deck.WriteTextOnImage(idx, img, k.Label, onImgFontName, onImgFontSize, pm.showLabelBackground); err != nil {
				slog.Warn("write text on image", "error", err)
				pm.deck.FillImage(idx, img)
			}
		} else {
			if err := pm.deck.FillImage(idx, img); err != nil {
				slog.Warn("fill image", "error", err)
			}
		}
	} else if k.Label != "" {
		if k.Background != "" {
			bg, err := parseHexColor(k.Background)
			if err == nil {
				ks := pm.deck.KeySize()
				img := image.NewRGBA(image.Rect(0, 0, ks, ks))
				draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
				if err := pm.deck.WriteTextOnImage(idx, img, k.Label, fontName, fontSize, pm.showLabelBackground); err != nil {
					slog.Warn("write text on image", "error", err)
					pm.deck.FillImage(idx, img)
				}
				return
			}
			slog.Warn("invalid background color", "value", k.Background, "error", err)
		}
		if err := pm.deck.WriteText(idx, k.Label, color.RGBA{0x21, 0x25, 0x2b, 0xff}, fontName, fontSize); err != nil {
			slog.Warn("write text", "error", err)
		}
	} else if k.Background != "" {
		bg, err := parseHexColor(k.Background)
		if err == nil {
			pm.deck.FillColor(idx, bg.R, bg.G, bg.B)
		} else {
			slog.Warn("invalid background color", "value", k.Background, "error", err)
		}
	}
}

func (pm *PageManager) stopPeriodicKeys() {
	pm.stopDynamicKeys()

	pm.mu.Lock()
	defer pm.mu.Unlock()
	for _, ks := range pm.keyStates {
		ks.cancel()
	}
	pm.keyStates = make(map[int]*keyState)
}

func (pm *PageManager) startPeriodicKeys() {
	pm.mu.Lock()
	page := pm.pages[pm.active]
	if page == nil {
		pm.mu.Unlock()
		return
	}

	type displayKey struct {
		index   int
		display *config.DisplayCfg
		ctx     context.Context
	}
	var keys []displayKey
	for _, k := range page.Keys {
		if k.Display != nil && k.Display.Interval != "" {
			interval, err := time.ParseDuration(k.Display.Interval)
			if err != nil || interval < time.Second {
				continue
			}
			if _, exists := pm.keyStates[k.Index]; exists {
				continue
			}
			ctx, cancel := context.WithCancel(context.Background())
			pm.keyStates[k.Index] = &keyState{cancel: cancel}
			keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
		}
	}
	dg := page.DynamicKeys
	pm.mu.Unlock()

	for _, dk := range keys {
		go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
	}

	if dg != nil {
		pm.startDynamicKeys(dg)
	}
}

func (pm *PageManager) stopDynamicKeys() {
	pm.dynamicKeysMu.Lock()
	if pm.dynamicCancel != nil {
		pm.dynamicCancel()
		pm.dynamicCancel = nil
	}
	pm.dynamicKeys = nil
	pm.dynamicKeysMu.Unlock()
}

func (pm *PageManager) startDynamicKeys(dg *config.DynamicKeyGen) {
	ctx, cancel := context.WithCancel(context.Background())

	pm.dynamicKeysMu.Lock()
	if pm.dynamicCancel != nil {
		pm.dynamicCancel()
	}
	pm.dynamicCancel = cancel
	pm.dynamicKeysMu.Unlock()

	numKeys := dg.MaxKeys
	if numKeys <= 0 {
		numKeys = pm.deck.NumKeys()
	}

	var interval time.Duration
	if dg.Interval != "" {
		interval, _ = time.ParseDuration(dg.Interval)
	}
	if interval == 0 {
		interval = 60 * time.Second
	}

	go pm.runDynamicKeyGenerator(ctx, dg, numKeys, interval)
}

func (pm *PageManager) runDynamicKeyGenerator(ctx context.Context, dg *config.DynamicKeyGen, numKeys int, interval time.Duration) {
	timeout := 15 * time.Second
	if dg.Timeout != "" {
		if t, err := time.ParseDuration(dg.Timeout); err == nil && t > 0 {
			timeout = t
		}
	}

	pm.updateDynamicKeys(dg, timeout, numKeys)

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			pm.updateDynamicKeys(dg, timeout, numKeys)
		case <-ctx.Done():
			return
		}
	}
}

func (pm *PageManager) updateDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, numKeys int) {
	keys, err := execDynamicKeys(dg, timeout, numKeys)
	if err != nil {
		slog.Warn("dynamic_keys generator failed", "error", err)
		return
	}

	if len(keys) > numKeys {
		keys = keys[:numKeys]
	}

	pm.mu.RLock()
	pageName := pm.active
	pm.mu.RUnlock()

	pm.dynamicKeysMu.Lock()
	if pm.dynamicCancel == nil {
		pm.dynamicKeysMu.Unlock()
		return
	}
	pm.dynamicKeys = keys
	pm.dynamicResults[pageName] = keys
	pm.dynamicKeysMu.Unlock()

	slog.Info("dynamic_keys updated", "count", len(keys), "page", pageName)

	pm.rerenderActivePage()
}

func (pm *PageManager) rerenderActivePage() {
	pm.mu.RLock()
	bg := ""
	if page := pm.pages[pm.active]; page != nil {
		bg = page.Background
	}
	pm.mu.RUnlock()

	if bg != "" {
		if img, err := loadImage(bg, 0, 0); err == nil {
			pm.deck.FillPanel(img)
		}
	}

	keys := pm.activeKeys()
	keyByIndex := make(map[int]config.KeyConfig, len(keys))
	for _, k := range keys {
		keyByIndex[k.Index] = k
	}

	for i := 0; i < pm.deck.NumKeys(); i++ {
		k, ok := keyByIndex[i]
		if !ok {
			if bg == "" {
				pm.deck.ClearKey(i)
			}
			continue
		}
		pm.renderKey(i, &k)
	}
}

func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
	timeout := 30 * time.Second
	if d.Timeout != "" {
		if t, err := time.ParseDuration(d.Timeout); err == nil && t > 0 {
			timeout = t
		}
	}
	interval, _ := time.ParseDuration(d.Interval)

	output, execErr := execDisplayCapture(d, timeout)
	pm.renderKeyOutput(idx, d, output, execErr)

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			output, execErr := execDisplayCapture(d, timeout)
			pm.renderKeyOutput(idx, d, output, execErr)
		case <-ctx.Done():
			return
		}
	}
}

type displayFormat struct {
	Text       string `json:"text"`
	Background string `json:"background,omitempty"`
	TextColor  string `json:"text_color,omitempty"`
}

func looksLikeHex(s string) bool {
	if len(s) != 7 && len(s) != 9 {
		return false
	}
	if s[0] != '#' {
		return false
	}
	for _, c := range s[1:] {
		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
			return false
		}
	}
	return true
}

func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color) {
	trimmed := strings.TrimSpace(output)
	if trimmed == "" {
		return &DisplayOutput{}, nil, nil
	}

	var do DisplayOutput
	var bgColor, fgColor color.Color
	if err := json.Unmarshal([]byte(trimmed), &do); err == nil && (do.Text != "" || do.Background != "") {
		if do.Background != "" {
			if c, err := parseHexColor(do.Background); err == nil {
				bgColor = c
			}
		}
		if do.TextColor != "" {
			if c, err := parseHexColor(do.TextColor); err == nil {
				fgColor = c
			}
		}
		do.Text = sanitizeText(do.Text)
		return &do, bgColor, fgColor
	}

	lines := strings.SplitN(trimmed, "\n", 2)
	if len(lines) > 0 {
		first := strings.TrimSpace(lines[0])
		if looksLikeHex(first) {
			do.Background = first
			if c, err := parseHexColor(first); err == nil {
				bgColor = c
			}
			if len(lines) > 1 {
				do.Text = sanitizeText(lines[1])
			}
			return &do, bgColor, fgColor
		}
	}

	do.Text = sanitizeText(trimmed)
	return &do, bgColor, fgColor
}

func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
	var kc *config.KeyConfig
	for _, k := range pm.activeKeys() {
		if k.Index == idx {
			kCopy := k
			kc = &kCopy
			break
		}
	}
	if kc == nil {
		return
	}

	if execErr != nil {
		slog.Warn("display command failed", "key", idx, "error", execErr)
	}

	do, bgOverride, fgOverride := parseDisplayOutput(output)
	fg := fgOverride
	if fg == nil {
		fg = color.White
	}

	if do.Text == "" && bgOverride == nil {
		do = &DisplayOutput{}
	}

	pm.displayMu.Lock()
	pm.displayOutputs[idx] = do
	pm.displayMu.Unlock()

	maxLen := d.MaxLen
	if maxLen <= 0 {
		maxLen = 128
	}
	if len(do.Text) > maxLen {
		do.Text = do.Text[:maxLen]
	}

	text := do.Text

	faScale := 0.55
	if kc.IconScale != nil {
		faScale = *kc.IconScale
	}

	fontSize := 12.0
	if kc.FontSize != nil {
		fontSize = *kc.FontSize
	}

	if kc.Icon != "" {
		img, err := loadImage(kc.Icon, pm.deck.KeySize(), faScale)
		if err != nil {
			slog.Warn("load icon for display", "path", kc.Icon, "error", err)
			var bg color.Color = color.Black
			if bgOverride != nil {
				bg = bgOverride
			}
			textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg)
			if err := pm.deck.FillImage(idx, textImg); err != nil {
				slog.Warn("fill image", "error", err)
			}
			return
		}

		if kc.Background != "" && bgOverride == nil {
			if bg, err := parseHexColor(kc.Background); err == nil {
				img = applyBackground(img, bg)
			} else {
				slog.Warn("invalid background color", "value", kc.Background, "error", err)
			}
		} else if bgOverride != nil {
			if rgba, ok := bgOverride.(color.RGBA); ok {
				img = applyBackground(img, rgba)
			}
		}

		composite := renderUnicodeTextOnImage(img, text, fontSize, pm.deck.KeySize(), fg, pm.showLabelBackground)
		if err := pm.deck.FillImage(idx, composite); err != nil {
			slog.Warn("fill image", "error", err)
		}
		return
	}

	var bg color.Color = color.Black
	if bgOverride != nil {
		bg = bgOverride
	} else if kc.Background != "" {
		if c, err := parseHexColor(kc.Background); err == nil {
			bg = c
		}
	}
	textImg := renderUnicodeText(text, fontSize, pm.deck.KeySize(), bg, fg)
	if err := pm.deck.FillImage(idx, textImg); err != nil {
		slog.Warn("fill image", "error", err)
	}
}

func (pm *PageManager) RefreshDisplayKey(idx int) {
	for _, k := range pm.activeKeys() {
		if k.Index == idx && k.Display != nil {
			timeout := 30 * time.Second
			if k.Display.Timeout != "" {
				if t, err := time.ParseDuration(k.Display.Timeout); err == nil && t > 0 {
					timeout = t
				}
			}
			output, err := execDisplayCapture(k.Display, timeout)
			pm.renderKeyOutput(idx, k.Display, output, err)
			return
		}
	}
}

var (
	unicodeFontOnce sync.Once
	unicodeFontData []byte
)

func loadUnicodeFontBytes() []byte {
	unicodeFontOnce.Do(func() {
		if data := fcRead(":charset=20BD"); data != nil {
			unicodeFontData = data
			return
		}
		if data := fcRead("mono"); data != nil {
			unicodeFontData = data
			return
		}
		if data := fcRead("sans"); data != nil {
			unicodeFontData = data
			return
		}
		unicodeFontData = goregular.TTF
	})
	return unicodeFontData
}

var (
	emojiFontOnce sync.Once
	emojiFontData []byte
)

func loadEmojiFontBytes() []byte {
	emojiFontOnce.Do(func() {
		if data := fcRead("emoji"); data != nil {
			if _, err := opentype.Parse(data); err == nil {
				emojiFontData = data
				return
			}
		}
		emojiFontData = loadUnicodeFontBytes()
	})
	return emojiFontData
}

func renderEmojiGlyph(r rune, size int, scale float64) image.Image {
	if img, ok := renderCBDTGlyph(r, size, scale); ok {
		return img
	}

	fontSize := float64(size) * scale
	data := loadEmojiFontBytes()
	fnt, err := opentype.Parse(data)
	if err != nil {
		return renderUnicodeText(string(r), fontSize, size, color.Black, color.White)
	}
	face, err := opentype.NewFace(fnt, &opentype.FaceOptions{
		Size:    fontSize,
		DPI:     72,
		Hinting: font.HintingFull,
	})
	if err != nil {
		return renderUnicodeText(string(r), fontSize, size, color.Black, color.White)
	}
	defer face.Close()

	rgba := image.NewRGBA(image.Rect(0, 0, size, size))

	adv := font.MeasureString(face, string(r)).Ceil()
	metrics := face.Metrics()
	height := metrics.Height.Ceil()

	x := (size - adv) / 2
	if x < 1 {
		x = 1
	}
	y := (size + height) / 2
	if y < 1 {
		y = 1
	}

	d := &font.Drawer{
		Dst:  rgba,
		Src:  image.NewUniform(color.White),
		Face: face,
		Dot:  fixed.P(x, y),
	}
	d.DrawString(string(r))
	return rgba
}

func fcRead(pattern string) []byte {
	cmd := exec.Command("fc-match", "-f", "%{file}", pattern)
	out, err := cmd.Output()
	if err != nil {
		return nil
	}
	path := strings.TrimSpace(string(out))
	if path == "" {
		return nil
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return nil
	}
	return data
}

func parseDisplayFace(size float64) (font.Face, error) {
	f, err := opentype.Parse(loadUnicodeFontBytes())
	if err != nil {
		return nil, err
	}
	return opentype.NewFace(f, &opentype.FaceOptions{
		Size:    size,
		DPI:     72,
		Hinting: font.HintingFull,
	})
}

func renderUnicodeText(text string, fontSize float64, keySize int, bg, fg color.Color) *image.RGBA {
	rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
	draw.Draw(rgba, rgba.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)

	if text == "" || fg == nil {
		return rgba
	}

	face, err := parseDisplayFace(fontSize)
	if err != nil {
		return rgba
	}
	defer face.Close()

	textAdvance := font.MeasureString(face, text).Ceil()
	metrics := face.Metrics()
	height := metrics.Height.Ceil()

	x := (keySize - textAdvance) / 2
	if x < 1 {
		x = 1
	}
	y := (keySize + height) / 2
	if y < 1 {
		y = 1
	}

	d := &font.Drawer{
		Dst:  rgba,
		Src:  &image.Uniform{fg},
		Face: face,
		Dot:  fixed.P(x, y),
	}
	d.DrawString(text)

	return rgba
}

func renderUnicodeTextOnImage(baseImg image.Image, text string, fontSize float64, keySize int, fg color.Color, showLabelBackground bool) *image.RGBA {
	g := gift.New(gift.Resize(keySize, keySize, gift.LanczosResampling))
	rgba := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
	g.Draw(rgba, baseImg)

	barHeight := 20
	if keySize < 72 {
		barHeight = 16
	}
	if showLabelBackground {
		barRect := image.Rect(0, keySize-barHeight, keySize, keySize)
		draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
	}

	if text == "" || fg == nil {
		return rgba
	}

	face, err := parseDisplayFace(fontSize)
	if err != nil {
		return rgba
	}
	defer face.Close()

	textAdvance := font.MeasureString(face, text).Ceil()
	metrics := face.Metrics()
	ascent := metrics.Ascent.Ceil()

	x := (keySize - textAdvance) / 2
	if x < 1 {
		x = 1
	}
	y := keySize - barHeight/2 + ascent/2
	if y > keySize-2 {
		y = keySize - 2
	}

	d := &font.Drawer{
		Dst:  rgba,
		Src:  &image.Uniform{fg},
		Face: face,
		Dot:  fixed.P(x, y),
	}
	d.DrawString(text)

	return rgba
}

func sanitizeText(s string) string {
	text := strings.TrimSpace(s)
	if text == "" {
		return text
	}
	var b strings.Builder
	b.Grow(len(text))
	inEscape := false
	for _, r := range text {
		if inEscape {
			if unicode.IsLetter(r) {
				inEscape = false
			}
			continue
		}
		if r == 0x1b {
			inEscape = true
			continue
		}
		if unicode.IsPrint(r) || r == '\n' {
			b.WriteRune(r)
		}
	}
	return strings.TrimSpace(b.String())
}

func parseHexColor(s string) (color.RGBA, error) {
	if len(s) == 0 || s[0] != '#' {
		return color.RGBA{}, fmt.Errorf("invalid color format: %q", s)
	}
	raw := strings.TrimPrefix(s, "#")
	if len(raw) != 3 && len(raw) != 6 && len(raw) != 8 {
		return color.RGBA{}, fmt.Errorf("invalid color length: %q", s)
	}
	if len(raw) == 3 {
		raw = string([]byte{raw[0], raw[0], raw[1], raw[1], raw[2], raw[2]})
	}
	n, err := strconv.ParseUint(raw, 16, 64)
	if err != nil {
		return color.RGBA{}, fmt.Errorf("invalid hex color: %q: %w", s, err)
	}
	switch len(raw) {
	case 6:
		return color.RGBA{
			R: uint8(n >> 16),
			G: uint8(n >> 8),
			B: uint8(n),
			A: 255,
		}, nil
	case 8:
		return color.RGBA{
			R: uint8(n >> 24),
			G: uint8(n >> 16),
			B: uint8(n >> 8),
			A: uint8(n),
		}, nil
	}
	return color.RGBA{}, fmt.Errorf("unexpected hex length: %d", len(raw))
}

func applyBackground(img image.Image, bg color.RGBA) *image.RGBA {
	b := img.Bounds()
	rgba := image.NewRGBA(b)
	draw.Draw(rgba, b, &image.Uniform{bg}, image.Point{}, draw.Src)
	draw.Draw(rgba, b, img, image.Point{}, draw.Over)
	return rgba
}

var imageCache sync.Map

func isEmojiRef(path string) bool {
	return strings.HasPrefix(path, "emoji:")
}

func parseEmojiRef(path string) (rune, error) {
	raw := strings.TrimPrefix(path, "emoji:")
	if raw == "" {
		return 0, fmt.Errorf("empty emoji ref")
	}

	if strings.HasPrefix(raw, ":") && strings.HasSuffix(raw, ":") && len(raw) > 2 {
		name := strings.ToLower(raw[1 : len(raw)-1])
		if r, ok := emojiShortcodes[name]; ok {
			return r, nil
		}
		return 0, fmt.Errorf("unknown emoji shortcode: %s", name)
	}

	r, size := utf8.DecodeRuneInString(raw)
	if r == utf8.RuneError || size == 0 {
		return 0, fmt.Errorf("invalid emoji character")
	}
	return r, nil
}

func cacheKey(path string, targetSize int, faScale float64) string {
	return fmt.Sprintf("%s|%d|%.6f", path, targetSize, faScale)
}

func loadImage(path string, targetSize int, faScale float64) (image.Image, error) {
	key := cacheKey(path, targetSize, faScale)
	if cached, ok := imageCache.Load(key); ok {
		return cached.(image.Image), nil
	}

	if isFAIconRef(path) {
		style, name, err := parseFAIcon(path)
		if err != nil {
			return nil, err
		}
		img, err := renderFAGlyph(style, name, targetSize, faScale)
		if err != nil {
			return nil, err
		}
		imageCache.Store(key, img)
		return img, nil
	}

	if isEmojiRef(path) {
		r, err := parseEmojiRef(path)
		if err != nil {
			return nil, err
		}
		img := renderEmojiGlyph(r, targetSize, faScale)
		imageCache.Store(key, img)
		return img, nil
	}

	resolved := path
	if isSystemIconRef(path) {
		p, err := findSystemIcon(systemIconName(path), targetSize)
		if err != nil {
			return nil, err
		}
		resolved = p
	} else if !strings.HasPrefix(path, "/") {
		resolved = filepath.Join(configDir(), path)
	}

	if strings.HasSuffix(resolved, ".svg") {
		img, err := svgToPNG(resolved, targetSize, faScale)
		if err != nil {
			return nil, err
		}
		imageCache.Store(key, img)
		return img, nil
	}

	f, err := os.Open(resolved)
	if err != nil {
		return nil, fmt.Errorf("open: %w", err)
	}
	defer f.Close()

	img, _, err := image.Decode(f)
	if err != nil {
		return nil, fmt.Errorf("decode: %w", err)
	}

	// For raster images, apply scaling and centering when targetSize > 0
	if targetSize > 0 && faScale > 0 {
		displaySize := int(float64(targetSize) * faScale)
		if displaySize > targetSize {
			displaySize = targetSize
		}
		if displaySize < 1 {
			displaySize = 1
		}

		// Resize to displaySize preserving aspect ratio (crop to fill)
		if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
			g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor))
			scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
			g.Draw(scaled, img)
			img = scaled
		}

		// Center on targetSize canvas
		if displaySize < targetSize {
			canvas := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
			offX := (targetSize - displaySize) / 2
			offY := (targetSize - displaySize) / 2
			draw.Draw(canvas, image.Rect(offX, offY, offX+displaySize, offY+displaySize), img, image.Point{}, draw.Over)
			img = canvas
		}
	}

	imageCache.Store(key, img)
	return img, nil
}

var cachedConfigDir string

func configDir() string {
	if cachedConfigDir != "" {
		return cachedConfigDir
	}
	home, err := os.UserHomeDir()
	if err != nil {
		cachedConfigDir = "."
		return cachedConfigDir
	}
	cachedConfigDir = filepath.Join(home, ".config", "streamdeck-lets-go")
	return cachedConfigDir
}
