package tui

import (
	"fmt"
	"image"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"

	"skraak/tools"
	"skraak/utils"
)

// playbackTickMsg is sent every 50ms while audio is playing
type playbackTickMsg struct{}

// Styles
var (
	titleStyle = lipgloss.NewStyle().
			Bold(true).
			Foreground(lipgloss.Color("15")).
			Background(lipgloss.Color("62")).
			Padding(0, 1)

	labelStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("86"))

	errorStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("196"))

	helpStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("241"))

	helpDarkStyle = lipgloss.NewStyle().
			Foreground(lipgloss.Color("86"))

	commentBoxStyle = lipgloss.NewStyle().
			Border(lipgloss.RoundedBorder()).
			BorderForeground(lipgloss.Color("62")).
			Padding(0, 1)
)

// wrapText wraps text at word boundaries to fit within maxWidth.
// Returns multiple lines joined with newlines.
func wrapText(text string, maxWidth int) string {
	if len(text) <= maxWidth {
		return text
	}

	lines := strings.Split(text, "\n")
	var result []string

	for _, line := range lines {
		if len(line) <= maxWidth {
			result = append(result, line)
			continue
		}

		// Wrap at word boundaries
		words := strings.Fields(line)
		var currentLine string
		for _, word := range words {
			if len(currentLine)+len(word)+1 <= maxWidth {
				if currentLine == "" {
					currentLine = word
				} else {
					currentLine += " " + word
				}
			} else {
				if currentLine != "" {
					result = append(result, currentLine)
				}
				// If single word is longer than maxWidth, force break it
				if len(word) > maxWidth {
					result = append(result, word[:maxWidth])
					word = word[maxWidth:]
				}
				currentLine = word
			}
		}
		if currentLine != "" {
			result = append(result, currentLine)
		}
	}

	return strings.Join(result, "\n")
}

// Model holds TUI state
type Model struct {
	state        *tools.ClassifyState
	err          string
	quitting     bool
	bindingsHelp string // pre-computed bindings text

	// Comment dialog state
	commentMode   bool   // true when comment dialog is open
	commentText   string // current input text
	commentCursor int    // cursor position in comment text

	// Clip dialog state
	clipMode  bool   // true when clip dialog is open
	clipInput string // current prefix input

	// Shift+primary wait mode: when non-empty, the next keypress is looked up
	// in Config.SecondaryBindings[awaitingSecondaryFor] as a calltype key.
	awaitingSecondaryFor string

	// Image generation counter - incremented on each segment change,
	// used to discard stale inline images (sixel/iTerm).
	// Pointer so it survives BubbleTea's value-copy update cycle.
	imageGen *uint64
}

// New creates a new TUI model
func New(state *tools.ClassifyState) Model {
	// Pre-compute bindings help text, sorted letters a-z then digits 0-9
	// (other single-char keys sorted after).
	sorted := make([]tools.KeyBinding, len(state.Config.Bindings))
	copy(sorted, state.Config.Bindings)
	keyRank := func(k string) int {
		if len(k) == 0 {
			return 3
		}
		c := k[0]
		switch {
		case c >= 'a' && c <= 'z':
			return 0
		case c >= 'A' && c <= 'Z':
			return 1
		case c >= '0' && c <= '9':
			return 2
		default:
			return 3
		}
	}
	sort.SliceStable(sorted, func(i, j int) bool {
		ri, rj := keyRank(sorted[i].Key), keyRank(sorted[j].Key)
		if ri != rj {
			return ri < rj
		}
		return sorted[i].Key < sorted[j].Key
	})
	var bindings []string
	for _, b := range sorted {
		if b.CallType != "" {
			bindings = append(bindings, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))
		} else {
			bindings = append(bindings, fmt.Sprintf("%s=%s", b.Key, b.Species))
		}
	}
	bindingsHelp := strings.Join(bindings, "  ")

	gen := uint64(0)
	return Model{
		state:        state,
		bindingsHelp: bindingsHelp,
		imageGen:     &gen,
	}
}

func (m Model) protocol() utils.ImageProtocol {
	if m.state.Config.ITerm {
		return utils.ProtocolITerm
	}
	if m.state.Config.Sixel {
		return utils.ProtocolSixel
	}
	return utils.ProtocolKitty
}

// Init initializes the model
func (m Model) Init() tea.Cmd {
	return inlineImageCmd(m.state, m.protocol(), *m.imageGen, m.imageGen)
}

// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		return m.handleKey(msg)
	case playbackTickMsg:
		if m.state.Player == nil || !m.state.Player.IsPlaying() {
			return m, nil // done, triggers re-render to clear "Playing..." text
		}
		return m, playbackTick()
	}

	return m, nil
}

// segmentChangeCmd returns the appropriate command after a segment change.
// Clears screen then generates and writes the spectrogram image asynchronously.
func (m Model) segmentChangeCmd() tea.Cmd {
	(*m.imageGen)++
	gen := *m.imageGen
	return tea.Sequence(tea.ClearScreen, inlineImageCmd(m.state, m.protocol(), gen, m.imageGen))
}

func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	if m.commentMode {
		return m.handleCommentKey(msg)
	}
	if m.clipMode {
		return m.handleClipKey(msg)
	}

	m.err = ""

	if m.awaitingSecondaryFor != "" {
		if handled, model, cmd := m.handleSecondaryWait(msg); handled {
			return model, cmd
		}
	}

	if handled, model, cmd := m.handleSpecialKey(msg); handled {
		return model, cmd
	}

	return m.handleSwitchKey(msg)
}

// handleSecondaryWait handles keypresses while awaiting a secondary calltype key.
// Returns (true, model, cmd) if the key was consumed; (false, model, cmd) to fall through.
func (m Model) handleSecondaryWait(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
	primary := m.awaitingSecondaryFor
	m.awaitingSecondaryFor = ""

	if msg.Key().Code == tea.KeyEscape || msg.Key().Code == tea.KeyEsc {
		return true, m, nil
	}

	s := msg.String()
	if len(s) == 1 {
		if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {
			m.stopPlayer()
			m.state.ApplyCallTypeOnly(callType)
			if err := m.state.Save(); err != nil {
				m.err = err.Error()
			}
			model, cmd := m.advanceOrQuit()
			return true, model, cmd
		}
	}
	return false, m, nil
}

// handleSpecialKey handles single-key-code bindings (Enter, Esc, Space, Ctrl+S).
// Returns (true, model, cmd) if the key was consumed.
func (m Model) handleSpecialKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
	key := msg.Key()

	if key.Code == tea.KeyEnter || key.Code == tea.KeyKpEnter {
		speed := 1.0
		if key.Mod&tea.ModShift != 0 {
			speed = 0.5
		}
		if errMsg := playCurrentSegmentAtSpeed(m.state, speed); errMsg != "" {
			m.err = errMsg
		}
		return true, m, playbackTick()
	}

	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.stopPlayer()
		m.quitting = true
		return true, m, tea.Quit
	}

	if key.Code == tea.KeySpace {
		m.commentText = m.state.GetCurrentComment()
		m.commentCursor = len(m.commentText)
		m.commentMode = true
		return true, m, nil
	}

	if msg.String() == "ctrl+s" {
		m.clipInput = ""
		m.clipMode = true
		return true, m, nil
	}

	return false, m, nil
}

// handleSwitchKey handles string-based key bindings (ctrl+c, arrows, digits, bindings).
func (m Model) handleSwitchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	switch msg.String() {
	case "ctrl+c":
		m.stopPlayer()
		m.quitting = true
		return m, tea.Quit

	case ",", "left":
		m.stopPlayer()
		m.state.PrevSegment()
		return m, m.segmentChangeCmd()

	case ".", "right":
		m.stopPlayer()
		if !m.state.NextSegment() {
			m.quitting = true
			return m, tea.Quit
		}
		return m, m.segmentChangeCmd()

	case "ctrl+d":
		m.state.ToggleBookmark()
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
		}
		return m, nil

	case "ctrl+,":
		m.stopPlayer()
		if m.state.PrevBookmark() {
			return m, m.segmentChangeCmd()
		}
		m.err = "No bookmarks found"
		return m, nil

	case "ctrl+.":
		m.stopPlayer()
		if m.state.NextBookmark() {
			return m, m.segmentChangeCmd()
		}
		m.err = "No bookmarks found"
		return m, nil

	case "0":
		m.stopPlayer()
		if m.state.ConfirmLabel() {
			if err := m.state.Save(); err != nil {
				m.err = err.Error()
				return m, nil
			}
		}
		return m.advanceOrQuit()

	default:
		return m.handleBindingKey(msg)
	}
}

// handleBindingKey handles single-character key bindings (species/calltype shortcuts).
func (m Model) handleBindingKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	s := msg.String()
	if len(s) != 1 {
		return m, nil
	}

	k := s
	key := msg.Key()

	// Shift+letter: if the lowercase primary has secondary bindings,
	// label species-only and enter wait mode. Otherwise map to the
	// lowercase equivalent and dispatch as a normal primary keypress.
	if key.Mod&tea.ModShift != 0 {
		lower := strings.ToLower(s)
		if lower != s {
			if m.state.HasSecondary(lower) {
				if result := m.state.ParseKeyBuffer(lower); result != nil {
					m.stopPlayer()
					m.state.ApplyBinding(&tools.BindingResult{Species: result.Species})
					if err := m.state.Save(); err != nil {
						m.err = err.Error()
					}
					m.awaitingSecondaryFor = lower
					return m, nil
				}
			}
			k = lower
		}
	}

	if result := m.state.ParseKeyBuffer(k); result != nil {
		m.stopPlayer()
		m.state.ApplyBinding(result)
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
		}
		return m.advanceOrQuit()
	}
	return m, nil
}

// stopPlayer stops the audio player if it exists.
func (m Model) stopPlayer() {
	if m.state.Player != nil {
		m.state.Player.Stop()
	}
}

// advanceOrQuit advances to the next segment, or quits if none remain.
func (m Model) advanceOrQuit() (tea.Model, tea.Cmd) {
	if !m.state.NextSegment() {
		m.quitting = true
		return m, tea.Quit
	}
	return m, m.segmentChangeCmd()
}

// handleCommentKey handles key presses in comment mode
func (m Model) handleCommentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.Key()

	// Enter: save comment
	if key.Code == tea.KeyEnter {
		m.state.SetComment(m.commentText)
		if err := m.state.Save(); err != nil {
			m.err = err.Error()
		}
		m.commentMode = false
		return m, nil
	}

	// Escape: cancel
	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.commentMode = false
		return m, nil
	}

	// Navigation and editing keys
	if handled := m.handleCommentKeyCode(key); handled {
		return m, nil
	}

	// Ctrl combos
	if handled := m.handleCommentCtrl(msg.String()); handled {
		return m, nil
	}

	// Printable ASCII character (space handled above via KeySpace)
	s := msg.String()
	if len(s) == 1 && s[0] >= 33 && s[0] <= 126 {
		if len(m.commentText) < 140 {
			m.commentText = m.commentText[:m.commentCursor] + s + m.commentText[m.commentCursor:]
			m.commentCursor++
		}
	}
	return m, nil
}

// handleCommentKeyCode handles navigation and editing keys in comment mode.
// Returns true if the key was consumed.
func (m *Model) handleCommentKeyCode(key tea.Key) bool {
	switch key.Code {
	case tea.KeyLeft:
		if m.commentCursor > 0 {
			m.commentCursor--
		}
		return true
	case tea.KeyRight:
		if m.commentCursor < len(m.commentText) {
			m.commentCursor++
		}
		return true
	case tea.KeySpace:
		if len(m.commentText) < 140 {
			m.commentText = m.commentText[:m.commentCursor] + " " + m.commentText[m.commentCursor:]
			m.commentCursor++
		}
		return true
	case tea.KeyBackspace:
		if m.commentCursor > 0 {
			m.commentText = m.commentText[:m.commentCursor-1] + m.commentText[m.commentCursor:]
			m.commentCursor--
		}
		return true
	case tea.KeyDelete:
		if m.commentCursor < len(m.commentText) {
			m.commentText = m.commentText[:m.commentCursor] + m.commentText[m.commentCursor+1:]
		}
		return true
	}
	return false
}

// handleCommentCtrl handles ctrl-key combos in comment mode.
// Returns true if the key was consumed.
func (m *Model) handleCommentCtrl(s string) bool {
	switch s {
	case "ctrl+u":
		m.commentText = ""
		m.commentCursor = 0
		return true
	case "ctrl+a":
		m.commentCursor = 0
		return true
	case "ctrl+e":
		m.commentCursor = len(m.commentText)
		return true
	}
	return false
}

// handleClipKey handles key presses in clip mode
func (m Model) handleClipKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	key := msg.Key()

	// Enter: save clip
	if key.Code == tea.KeyEnter {
		if m.clipInput == "" {
			m.clipMode = false
			return m, nil
		}
		// Save the clip
		err := saveClip(m.state, m.clipInput)
		if err != nil {
			m.err = err.Error()
		} else {
			m.err = "Clip saved: " + m.clipInput
		}
		m.clipMode = false
		return m, nil
	}

	// Escape: cancel
	if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {
		m.clipMode = false
		return m, nil
	}

	// Backspace: remove last character
	if key.Code == tea.KeyBackspace {
		if len(m.clipInput) > 0 {
			m.clipInput = m.clipInput[:len(m.clipInput)-1]
		}
		return m, nil
	}

	// Printable characters: append to input
	s := msg.String()
	if len(s) == 1 && s[0] >= 32 && s[0] <= 126 { // printable ASCII
		if len(m.clipInput) < 64 {
			m.clipInput += s
		}
		return m, nil
	}

	return m, nil
}

// saveClip saves a clip of the current segment to the current working directory
func saveClip(state *tools.ClassifyState, prefix string) error {
	df := state.CurrentFile()
	seg := state.CurrentSegment()
	if df == nil || seg == nil {
		return fmt.Errorf("no segment selected")
	}

	pngPath, wavOutPath, err := buildClipPaths(df, seg, prefix)
	if err != nil {
		return err
	}

	// Read WAV samples
	wavPath := strings.TrimSuffix(df.FilePath, ".data")
	samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
	if err != nil {
		return fmt.Errorf("failed to read WAV: %w", err)
	}

	// Extract segment samples
	segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
	if len(segSamples) == 0 {
		return fmt.Errorf("no samples in segment")
	}

	// Determine output sample rate (downsample if > 16kHz)
	outputSampleRate := sampleRate
	if sampleRate > utils.DefaultMaxSampleRate {
		segSamples = utils.ResampleRate(segSamples, sampleRate, utils.DefaultMaxSampleRate)
		outputSampleRate = utils.DefaultMaxSampleRate
	}

	// Generate spectrogram image
	resized, err := generateClipSpectrogram(segSamples, outputSampleRate)
	if err != nil {
		return err
	}

	// Write output files
	if err := writeClipPNG(resized, pngPath); err != nil {
		return err
	}
	if err := utils.WriteWAVFile(wavOutPath, segSamples, outputSampleRate); err != nil {
		return fmt.Errorf("failed to write WAV: %w", err)
	}

	return nil
}

// writeClipPNG writes a spectrogram image to a PNG file with proper cleanup.
func writeClipPNG(img image.Image, path string) error {
	pngFile, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("failed to create PNG: %w", err)
	}
	if err := utils.WritePNG(img, pngFile); err != nil {
		_ = pngFile.Close()
		return fmt.Errorf("failed to write PNG: %w", err)
	}
	if err := pngFile.Close(); err != nil {
		return fmt.Errorf("failed to close PNG: %w", err)
	}
	return nil
}

// buildClipPaths constructs output file paths for a clip and checks they don't already exist.
func buildClipPaths(df *utils.DataFile, seg *utils.Segment, prefix string) (pngPath, wavOutPath string, err error) {
	wavPath := strings.TrimSuffix(df.FilePath, ".data")
	basename := wavPath[strings.LastIndex(wavPath, "/")+1:]
	basename = strings.TrimSuffix(basename, ".wav")

	startInt := int(seg.StartTime)
	endInt := int(seg.EndTime)
	if seg.EndTime > float64(endInt) {
		endInt++
	}

	cwd, err := os.Getwd()
	if err != nil {
		return "", "", fmt.Errorf("failed to get working directory: %w", err)
	}

	baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)
	pngPath = filepath.Join(cwd, baseName+".png")
	wavOutPath = filepath.Join(cwd, baseName+".wav")

	if _, err := os.Stat(pngPath); err == nil {
		return "", "", fmt.Errorf("file already exists: %s", pngPath)
	}
	if _, err := os.Stat(wavOutPath); err == nil {
		return "", "", fmt.Errorf("file already exists: %s", wavOutPath)
	}

	return pngPath, wavOutPath, nil
}

// generateClipSpectrogram generates a 224px color spectrogram image from audio samples.
func generateClipSpectrogram(segSamples []float64, sampleRate int) (image.Image, error) {
	config := utils.DefaultSpectrogramConfig(sampleRate)
	spectrogram := utils.GenerateSpectrogram(segSamples, config)
	if spectrogram == nil {
		return nil, fmt.Errorf("failed to generate spectrogram")
	}

	colorData := utils.ApplyL4Colormap(spectrogram)
	img := utils.CreateRGBImage(colorData)
	if img == nil {
		return nil, fmt.Errorf("failed to create image")
	}

	return utils.ResizeImage(img, 224, 224), nil
}

// playCurrentSegmentAtSpeed loads and plays the current segment's audio at the given speed.
// speed=1.0 is normal, speed=0.5 is half speed.
// Returns an error message string, or empty string on success.
func playCurrentSegmentAtSpeed(state *tools.ClassifyState, speed float64) string {
	df := state.CurrentFile()
	seg := state.CurrentSegment()
	if df == nil || seg == nil {
		return ""
	}

	wavPath := strings.TrimSuffix(df.FilePath, ".data")
	samples, sampleRate, err := utils.ReadWAVSamples(wavPath)
	if err != nil {
		return fmt.Sprintf("audio: %v", err)
	}

	// Initialize player lazily on first play
	if state.Player == nil {
		player, err := utils.NewAudioPlayer(sampleRate)
		if err != nil {
			return fmt.Sprintf("audio init: %v", err)
		}
		state.Player = player
	}

	segSamples := utils.ExtractSegmentSamples(samples, sampleRate, seg.StartTime, seg.EndTime)
	if len(segSamples) > 0 {
		state.PlaybackSpeed = speed
		state.Player.PlayAtSpeed(segSamples, sampleRate, speed)
	}
	return ""
}

// playbackTick returns a command that sends a playbackTickMsg after 50ms.
func playbackTick() tea.Cmd {
	return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
		return playbackTickMsg{}
	})
}

// View renders the TUI
func (m Model) View() tea.View {
	if m.quitting {
		var b strings.Builder
		_ = utils.ClearImages(&b, m.protocol())
		b.WriteString("\nDone!\n")
		return tea.NewView(b.String())
	}

	var b strings.Builder

	// Header: file info
	df := m.state.CurrentFile()
	seg := m.state.CurrentSegment()
	total := m.state.TotalSegments()
	current := m.state.CurrentSegmentNumber()

	if df == nil || seg == nil {
		return tea.NewView("\nNo segments to review.\n")
	}

	// Bindings help (wrap at 80 chars)
	const wrapWidth = 80
	b.WriteString(helpStyle.Render(wrapText(m.bindingsHelp, wrapWidth)))
	b.WriteString("\n")
	b.WriteString(helpDarkStyle.Render(wrapText("[esc]quit [,]prev [.]next [0]confirm [space]comment [ctrl+s]clip [ctrl+d]bookmark [ctrl+,]prev-bk [ctrl+.]next-bk [enter]play [shift+enter]½speed", wrapWidth)))
	b.WriteString("\n\n")

	// Progress bar
	progress := float64(current) / float64(total)
	barWidth := 30
	filled := int(progress * float64(barWidth))
	bar := strings.Repeat("", filled) + strings.Repeat("", barWidth-filled)

	// Title line
	wavFile := strings.TrimSuffix(df.FilePath, ".data")
	wavFile = wavFile[strings.LastIndex(wavFile, "/")+1:]
	b.WriteString(titleStyle.Render(fmt.Sprintf(" %s [%s] %d/%d Segments ", wavFile, bar, current, total)))
	b.WriteString("\n\n")

	// Segment info
	segInfo := fmt.Sprintf("Segment: %.1fs - %.1fs (%.1fs)", seg.StartTime, seg.EndTime, seg.EndTime-seg.StartTime)
	if m.state.HasBookmark() {
		segInfo += " [BOOKMARKED]"
	}
	if m.awaitingSecondaryFor != "" {
		segInfo += "  Waiting..."
	}
	if m.state.Player != nil && m.state.Player.IsPlaying() {
		if m.state.PlaybackSpeed == 0.5 {
			segInfo += "  ▶ Playing 0.5x..."
		} else {
			segInfo += "  ▶ Playing..."
		}
	}
	b.WriteString(segInfo)
	b.WriteString("\n\n")

	// Labels
	filterLabels := seg.GetFilterLabels(m.state.Config.Filter)
	if len(filterLabels) > 0 {
		b.WriteString(labelStyle.Render("Labels:"))
		b.WriteString("\n")
		for _, l := range filterLabels {
			fmt.Fprintf(&b, "%s\n", tools.FormatLabels([]*utils.Label{l}, m.state.Config.Filter))
		}
	}
	b.WriteString("\n")

	// Clip dialog (when active)
	if m.clipMode {
		m.renderClipDialog(&b)
		return tea.NewView(b.String())
	}

	// Comment dialog (when active)
	if m.commentMode {
		m.renderCommentDialog(&b)
		return tea.NewView(b.String())
	}

	// Error
	if m.err != "" {
		b.WriteString(errorStyle.Render(m.err))
	}

	v := tea.NewView(b.String())
	v.AltScreen = true
	return v
}

// renderCommentDialog renders the comment input dialog
func (m Model) renderCommentDialog(b *strings.Builder) {
	// Build input line with cursor at correct position
	before := m.commentText[:m.commentCursor]
	after := m.commentText[m.commentCursor:]
	inputLine := before + "" + after

	charCount := fmt.Sprintf("%d/140", len(m.commentText))
	helpLine := "[enter]save  [esc]cancel  [←→]move  [ctrl+u]clear  [ctrl+a]start  [ctrl+e]end"

	// Render box
	content := fmt.Sprintf("Comment:\n%s\n%s\n%s", inputLine, charCount, helpLine)
	b.WriteString(commentBoxStyle.Render(content))
}

// renderClipDialog renders the clip prefix input dialog
func (m Model) renderClipDialog(b *strings.Builder) {
	inputLine := m.clipInput + ""
	helpLine := "[enter]save  [esc]cancel"

	// Render box
	content := fmt.Sprintf("Clip prefix:\n%s\n%s", inputLine, helpLine)
	b.WriteString(commentBoxStyle.Render(content))
}

// generateSpectrogramImage creates a resized spectrogram image from a segment.
func generateSpectrogramImage(state *tools.ClassifyState, dataPath string, seg *utils.Segment) image.Image {
	imgSize := state.Config.ImageSize
	if imgSize == 0 {
		imgSize = utils.SpectrogramDisplaySize
	}
	img, err := utils.GenerateSegmentSpectrogram(dataPath, seg.StartTime, seg.EndTime, state.Config.Color, imgSize)
	if err != nil {
		return nil
	}
	return img
}

// inlineImageCmd returns a tea.Cmd that generates and writes an inline image
// directly to the terminal, bypassing BubbleTea's renderer.
// gen is the generation at dispatch time; currentGen points to the live counter.
// If they differ when the image is ready, a newer segment change has occurred
// and this image is stale — discard it instead of writing.
func inlineImageCmd(state *tools.ClassifyState, protocol utils.ImageProtocol, gen uint64, currentGen *uint64) tea.Cmd {
	return func() tea.Msg {
		df := state.CurrentFile()
		seg := state.CurrentSegment()
		if df == nil || seg == nil {
			return nil
		}

		img := generateSpectrogramImage(state, df.FilePath, seg)
		if img == nil {
			return nil
		}

		// Discard if a newer segment change has superseded this one
		if *currentGen != gen {
			return nil
		}

		// Clear previous kitty images before writing new one.
		// Terminal write errors during render are non-recoverable; ignore.
		_ = utils.ClearImages(os.Stdout, protocol)
		_, _ = fmt.Fprint(os.Stdout, "\r\n\r\n")
		_ = utils.WriteImage(img, os.Stdout, protocol)
		return nil
	}
}