package model

import (
	"fmt"
	"math"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

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

	"cliamp/playlist"
	"cliamp/provider"
	"cliamp/theme"
	"cliamp/ui"
)

// titleScrollSep is the separator runes for cyclic title scrolling,
// pre-allocated to avoid per-frame conversion.
var titleScrollSep = []rune("   ♫   ")

// Pre-built styles for elements created per-render to avoid repeated allocation.
var (
	seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
	seekDimStyle  = lipgloss.NewStyle().Foreground(ui.ColorDim)
	volBarStyle   = lipgloss.NewStyle().Foreground(ui.ColorVolume)
	activeToggle  = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
)

// providerEmptyStateHint, keyed by lowercase provider Name(), returns the
// remediation hint shown under the generic "No playlists in X" message.
var providerEmptyStateHint = map[string]string{
	"local playlists":     "Add .toml playlists to ~/.config/cliamp/playlists/.",
	"local":               "Add .toml playlists to ~/.config/cliamp/playlists/.",
	"spotify":             "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
	"navidrome":           "Verify [navidrome] url/username/password in config.toml.",
	"jellyfin":            "Verify [jellyfin] url and token in config.toml.",
	"emby":                "Verify [emby] url and token or username/password in config.toml.",
	"plex":                "Verify [plex] server URL and token or library filter in config.toml.",
	"youtube music":       "Run `cliamp ytmusic-login` to authorize, then refresh.",
	"ytmusic":             "Run `cliamp ytmusic-login` to authorize, then refresh.",
	"soundcloud":          "Set [soundcloud] user in config.toml to browse a profile.",
	"netease cloud music": "Run `cliamp setup` and configure NetEase browser cookies.",
}

// renderProviderEmptyState explains why the playlists pane is empty for the
// current provider and offers a remediation hint. Always pads to budget so the
// pane height stays stable.
func (m Model) renderProviderEmptyState(budget int) string {
	name := "this provider"
	if m.provider != nil {
		name = m.provider.Name()
	}
	lines := []string{
		dimStyle.Render(fmt.Sprintf("  No playlists in %s.", name)),
		"",
	}
	if _, searchable := m.provider.(provider.Searcher); searchable {
		lines = append(lines,
			dimStyle.Render("  Press ")+helpKeyStyle.Render(" Ctrl+F ")+dimStyle.Render(" to search."))
	}
	if m.provider != nil {
		if hint, ok := providerEmptyStateHint[strings.ToLower(m.provider.Name())]; ok {
			lines = append(lines, dimStyle.Render("  "+hint))
		}
	}
	return strings.Join(fitLines(lines, budget), "\n")
}

// providerRowStyle picks the prefix and style for a provider-list row.
// Cursor takes precedence; "currently loaded" gets the active-track style and
// the ▶ prefix so users can see at a glance which playlist is in the queue.
func (m Model) providerRowStyle(p playlist.PlaylistInfo, isCursor bool) (string, lipgloss.Style) {
	if isCursor {
		return "> ", playlistSelectedStyle
	}
	if m.isProviderRowActive(p) {
		return "▶ ", playlistActiveStyle
	}
	return "  ", playlistItemStyle
}

// isProviderRowActive reports whether the given playlist is the one whose
// tracks are currently loaded into the player.
func (m Model) isProviderRowActive(p playlist.PlaylistInfo) bool {
	if m.activeProviderPlaylistID != "" && m.activeProviderPlaylistID == p.ID {
		return true
	}
	if m.loadedPlaylist != "" && m.loadedPlaylist == p.Name {
		return true
	}
	return false
}

// playlistLabel formats a playlist entry, omitting fields the provider didn't
// supply. Track count and total duration are appended when available.
func playlistLabel(prefix string, p playlist.PlaylistInfo) string {
	out := prefix + p.Name
	parts := make([]string, 0, 2)
	if p.TrackCount > 0 {
		parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount))
	}
	if d := formatPlaylistDuration(p.DurationSecs); d != "" {
		parts = append(parts, d)
	}
	if len(parts) > 0 {
		out += " · " + strings.Join(parts, " · ")
	}
	return out
}

// View renders the full TUI frame.
func (m Model) View() tea.View {
	if m.quitting {
		return tea.NewView("")
	}

	screen := m.activeScreen()
	if !screen.hidesVisualizer() {
		m.refreshVisualizerIfPending()
	}

	var content string
	switch screen {
	case screenFullVisualizer:
		content = m.renderFullVisualizer()
	default:
		// Every overlay renders inline in the playlist region (renderMainBody),
		// with its header/help supplied by renderPlaylistHeader / renderHelp, so
		// the now-playing + visualizer chrome stays live above and the layout
		// height never shifts when an overlay opens.
		content = strings.Join(m.mainSections(m.renderMainBody(), true), "\n")
	}

	// Every screen now renders within the main frame, so frame and center
	// uniformly.
	rendered := m.centerFrame(ui.FrameStyle.Render(content))

	view := tea.NewView(rendered)
	view.AltScreen = true
	view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues())
	return view
}

func trimTrailingEmpty(sections []string) []string {
	for len(sections) > 0 && sections[len(sections)-1] == "" {
		sections = sections[:len(sections)-1]
	}
	return sections
}

func (m Model) mainSections(playlist string, includeTransient bool) []string {
	sections := []string{
		// Now playing
		m.renderTitle(),
		m.renderTrackInfo(),
		m.renderTimeStatus(),
		"",
		// ui.Visualizer
		m.renderSpectrum(),
		m.renderSeekBar(),
		"",
		// Controls
		m.renderControls(),
		m.renderProviderPill(),
		"",
		// Playlist
		m.renderPlaylistHeader(),
	}
	if playlist != "" {
		sections = append(sections, playlist)
	}
	sections = append(sections,
		"",
		// Help
		m.renderHelp(),
		m.renderBottomStatus(),
	)

	if includeTransient {
		if m.err != nil {
			sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)))
		}
		sections = append(sections, m.footerMessages()...)
	}

	return trimTrailingEmpty(sections)
}

func (m Model) footerMessages() []string {
	var lines []string
	if text := m.save.activityText(); text != "" {
		lines = append(lines, statusStyle.Render(text))
	}
	if m.status.text != "" {
		lines = append(lines, statusStyle.Render(m.status.text))
	}
	for _, l := range m.logLines {
		lines = append(lines, dimStyle.Render(l.text))
	}
	return lines
}

// centerFrame centers a pre-rendered frame in the terminal using plain string
// padding instead of allocating a new lipgloss.Style every render.
func (m Model) centerFrame(frame string) string {
	frameW := lipgloss.Width(frame)
	frameH := lipgloss.Height(frame)
	padLeft := max(0, (m.width-frameW)/2)
	padTop := max(0, (m.height-frameH)/2)

	if padLeft == 0 {
		return strings.Repeat("\n", padTop) + frame
	}
	// Indent every line by padLeft spaces.
	prefix := strings.Repeat(" ", padLeft)
	lines := strings.Split(frame, "\n")
	for i, l := range lines {
		lines[i] = prefix + l
	}
	return strings.Repeat("\n", padTop) + strings.Join(lines, "\n")
}

func (m Model) renderTitle() string {
	title := titleStyle.Render("C L I A M P")
	label := m.focus.label()
	if label == "" {
		return title
	}
	indicator := dimStyle.Render("[" + label + "]")
	gap := max(ui.PanelWidth-lipgloss.Width(title)-lipgloss.Width(indicator), 1)
	return title + strings.Repeat(" ", gap) + indicator
}

func (m Model) renderTrackInfo() string {
	track, _ := m.playlist.Current()
	name := track.DisplayName()
	if name == "" {
		name = "No track loaded"
	}
	// Show live ICY stream title instead of static track name for radio streams.
	if m.streamTitle != "" && track.Stream {
		name = m.streamTitle
	}

	// Append album to the title line to save vertical space.
	// The album is truncated (never scrolled) so artist/song stays readable.
	album := track.Album
	if m.streamTitle != "" && track.Stream {
		album = ""
	}

	maxW := ui.PanelWidth - 4
	if maxW < 1 {
		return trackStyle.Render("♫ " + name)
	}
	nameRunes := []rune(name)

	if album != "" {
		sep := " · "
		sepLen := len([]rune(sep))
		remaining := maxW - len(nameRunes) - sepLen
		if remaining >= 4 {
			name += sep + truncate(album, remaining)
		}
		// remaining < 4: drop album, name alone fits or scrolls below.
	}

	runes := []rune(name)

	if len(runes) <= maxW {
		return trackStyle.Render("♫ " + name)
	}
	// Cyclic scrolling for long titles (only artist/song, album already handled)
	padded := append(runes, titleScrollSep...)
	total := len(padded)
	off := m.titleOff % total

	display := make([]rune, maxW)
	for i := range maxW {
		display[i] = padded[(off+i)%total]
	}
	return trackStyle.Render("♫ " + string(display))
}

func (m Model) renderTimeStatus() string {
	// Use per-tick cached values to avoid repeated speaker.Lock() calls.
	pos := m.cachedPos
	dur := m.cachedDur

	posMin := int(pos.Minutes())
	posSec := int(pos.Seconds()) % 60
	durMin := int(dur.Minutes())
	durSec := int(dur.Seconds()) % 60

	timeStr := fmt.Sprintf("%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec)

	track, _ := m.playlist.Current()

	var status string
	switch {
	case m.seek.active:
		status = statusStyle.Render("⟳ Seeking...")
	case m.buffering:
		if elapsed := int(time.Since(m.bufferingAt).Seconds()); elapsed > 0 {
			status = statusStyle.Render(fmt.Sprintf("◌ Buffering... (%ds)", elapsed))
		} else {
			status = statusStyle.Render("◌ Buffering...")
		}
	case m.player.IsPlaying() && m.player.IsPaused():
		status = statusStyle.Render("⏸ Paused")
	case m.player.IsPlaying() && track.Stream:
		status = statusStyle.Render("● Streaming")
	case m.player.IsPlaying():
		status = statusStyle.Render("▶ Playing")
	default:
		status = dimStyle.Render("■ Stopped")
	}

	left := timeStyle.Render(timeStr)
	gap := max(ui.PanelWidth-lipgloss.Width(left)-lipgloss.Width(status), 1)

	return left + strings.Repeat(" ", gap) + status
}

func (m Model) renderSpectrum() string {
	if m.vis.Mode == ui.VisNone {
		return ""
	}
	return m.vis.Render()
}

// renderFullVisualizer renders a full-screen view showing only the visualizer
// with minimal track info and a seek bar.
func (m Model) renderFullVisualizer() string {
	sections := []string{
		m.renderTrackInfo(),
		m.renderTimeStatus(),
		"",
		m.renderSpectrum(),
		m.renderSeekBar(),
		"",
		helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol"),
	}

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

func (m Model) renderSeekBar() string {
	if ui.PanelWidth <= 0 {
		return ""
	}
	// During buffering, show a dim bar — avoids speaker.Lock() contention.
	if m.buffering {
		return seekDimStyle.Render(strings.Repeat("━", ui.PanelWidth))
	}
	// Show a static streaming bar for non-seekable streams with no known duration.
	if !m.player.Seekable() && m.player.IsPlaying() && m.cachedDur == 0 {
		label := " STREAMING "
		pad := ui.PanelWidth - lipgloss.Width(label)
		if pad < 0 {
			return seekFillStyle.Render(label[:ui.PanelWidth])
		}
		left := pad / 2
		right := pad - left
		return seekFillStyle.Render(strings.Repeat("━", left) + label + strings.Repeat("━", right))
	}

	pos := m.cachedPos
	dur := m.cachedDur

	var progress float64
	if dur > 0 {
		progress = float64(pos) / float64(dur)
	}
	progress = max(0, min(1, progress))

	// Half-cell resolution: each cell is two sub-units, so a 4-minute track on
	// an 80-wide bar advances the tip every ~1.5s instead of ~3s. The
	// transition cell uses ╸ (heavy left-half) as a half-step stub.
	w := ui.PanelWidth
	subPos := int(progress * 2 * float64(w))
	fullCells := subPos / 2
	hasHalf := subPos%2 == 1 && fullCells < w

	var b strings.Builder
	b.WriteString(seekFillStyle.Render(strings.Repeat("━", fullCells)))
	if hasHalf {
		b.WriteString(seekFillStyle.Render("╸"))
		b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells-1)))
	} else {
		b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells)))
	}
	return b.String()
}

func (m Model) renderControls() string {
	// ── EQ [Preset] (left)  ·····  VOL bar dB [Mono] (right) ──

	bands := m.player.EQBands()
	presetName := m.EQPresetName()

	eqParts := make([]string, 10)
	eqLabels := [10]string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"}
	for i, label := range eqLabels {
		style := eqInactiveStyle
		if bands[i] != 0 {
			label = fmt.Sprintf("%+.0f", bands[i])
		}
		if m.focus == focusEQ && i == m.eqCursor {
			style = eqActiveStyle
		}
		eqParts[i] = style.Render(label)
	}

	eqLabel := labelStyle.Render("EQ ")
	if m.focus == focusEQ {
		eqLabel = activeToggle.Render("EQ ▸ ")
	}
	left := eqLabel + dimStyle.Render("[") + activeToggle.Render(presetName) + dimStyle.Render("] ") + strings.Join(eqParts, " ")

	vol := m.player.Volume()
	volMin := m.player.VolumeMin()
	frac := max(0, min(1, (vol-volMin)/(6-volMin)))
	dbStr := fmt.Sprintf(" %+.0fdB", vol)
	monoStr := ""
	if m.player.Mono() {
		monoStr = " " + activeToggle.Render("[M]")
	}

	leftW := lipgloss.Width(left)
	volLabel := labelStyle.Render("VOL ")
	volSuffix := dimStyle.Render(dbStr) + monoStr
	volLabelW := lipgloss.Width(volLabel)
	volSuffixW := lipgloss.Width(volSuffix)
	barW := max(6, (ui.PanelWidth-leftW-2-volLabelW-volSuffixW)*3/4)
	filled := int(frac * float64(barW))

	bar := volBarStyle.Render(strings.Repeat("█", filled)) +
		dimStyle.Render(strings.Repeat("░", barW-filled))

	right := volLabel + bar + volSuffix
	rightW := lipgloss.Width(right)
	gap := max(1, ui.PanelWidth-leftW-rightW)

	return left + strings.Repeat(" ", gap) + right
}

func (m Model) renderProviderPill() string {
	if len(m.providers) <= 1 {
		return ""
	}

	var pills []string
	for i, pe := range m.providers {
		name := pe.Name
		if m.focus == focusProvPill && i == m.provPillIdx {
			pills = append(pills, activeToggle.Render("["+name+"]"))
		} else if i == m.provPillIdx {
			pills = append(pills, dimStyle.Render("[")+trackStyle.Render(name)+dimStyle.Render("]"))
		} else {
			pills = append(pills, dimStyle.Render("["+name+"]"))
		}
	}

	srcLabel := labelStyle.Render("SRC ")
	if m.focus == focusProvPill {
		srcLabel = activeToggle.Render("SRC ▸ ")
	}
	return srcLabel + strings.Join(pills, " ")
}

func (m Model) renderPlaylistHeader() string {
	if ov, ok := m.activeOverlay(); ok {
		return ov.header(&m)
	}
	if m.focus == focusProvider {
		return dimStyle.Render(labeledSeparator("", fmt.Sprintf("%s Playlists", m.provider.Name())))
	}

	var shuffle string
	if m.playlist.Shuffled() {
		shuffle = activeToggle.Render("[Shuffle]")
	} else {
		shuffle = dimStyle.Render("[") + trackStyle.Render("Shuffle") + dimStyle.Render("]")
	}

	repeatVal := m.playlist.Repeat().String()
	if m.playlist.Repeat() != 0 {
		repeatStr := fmt.Sprintf("[Repeat: %s]", repeatVal)
		repeatStr = activeToggle.Render(repeatStr)
		shuffle += " " + repeatStr
	} else {
		repeatStr := dimStyle.Render("[") + trackStyle.Render("Repeat") + dimStyle.Render(": ") + dimStyle.Render(repeatVal) + dimStyle.Render("]")
		shuffle += " " + repeatStr
	}

	var queueStr string
	if qLen := m.playlist.QueueLen(); qLen > 0 {
		queueStr = " " + activeToggle.Render(fmt.Sprintf("[Queue: %d]", qLen))
	}

	var bookmarkStr string
	if bookmarkCount := m.playlist.BookmarkCount(); bookmarkCount > 0 {
		bookmarkStr = " " + activeToggle.Render(fmt.Sprintf("[★ %d]", bookmarkCount))
	}

	var themeStr string
	if name := m.ThemeName(); name != theme.DefaultName {
		themeStr = " " + activeToggle.Render("[Theme: "+name+"]")
	}

	var posStr string
	if total := m.playlist.Len(); total > 0 {
		posStr = " " + dimStyle.Render(fmt.Sprintf("[%d/%d]", m.playlist.Index()+1, total))
	}

	headerStyle := dimStyle
	headerLabel := "── Playlist ── "
	if m.focus == focusPlaylist {
		headerStyle = activeToggle
		headerLabel = "▸─ Playlist ── "
	}
	return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + posStr + themeStr + " " + dimStyle.Render("──")
}

func (m Model) renderProviderList() string {
	visibleBudget := m.effectivePlaylistVisible()
	if visibleBudget <= 0 {
		return ""
	}
	if m.provSignIn {
		return dimStyle.Render(fmt.Sprintf("  Sign in to %s. Press Enter to continue.", m.provider.Name()))
	}
	if m.provLoading {
		lines := []string{loadingLine(fmt.Sprintf("Loading %s…", m.provider.Name()))}
		if m.provAuthURL != "" {
			lines = append(lines,
				"",
				dimStyle.Render("  If your browser didn't open, visit this URL to sign in:"),
				"  "+m.provAuthURL,
			)
		}
		for len(lines) < visibleBudget {
			lines = append(lines, "")
		}
		return strings.Join(lines, "\n")
	}
	if len(m.providerLists) == 0 {
		return m.renderProviderEmptyState(visibleBudget)
	}

	sl, isRadio := m.provider.(provider.SectionedList)
	var lines []string

	if m.provSearch.active {
		lines = append(lines, playlistSelectedStyle.Render("  / "+m.provSearch.query+"_"))

		if isRadio {
			if m.provSearch.query == "" {
				lines = append(lines, dimStyle.Render("  Type a station name, Enter to search…"))
			} else {
				lines = append(lines, dimStyle.Render("  Press Enter to search"))
			}
		} else {
			if m.provSearch.query == "" {
				lines = append(lines, dimStyle.Render("  Type to filter…"))
			} else if len(m.provSearch.results) == 0 {
				lines = append(lines, dimStyle.Render("  No matches"))
			} else {
				visible := max(0, min(visibleBudget-1, len(m.provSearch.results)))
				scroll := m.provSearch.scroll
				for j := scroll; j < scroll+visible && j < len(m.provSearch.results); j++ {
					idx := m.provSearch.results[j]
					p := m.providerLists[idx]
					prefix, style := m.providerRowStyle(p, j == m.provSearch.cursor)
					lines = append(lines, style.Render(playlistLabel(prefix, p)))
				}
				lines = append(lines, dimStyle.Render(fmt.Sprintf("  %d/%d playlists", len(m.provSearch.results), len(m.providerLists))))
			}
		}
	} else {
		scroll := max(0, m.provScroll)
		if scroll >= len(m.providerLists) {
			scroll = max(0, len(m.providerLists)-1)
		}
		if m.provCursor < scroll {
			scroll = m.provCursor
		}

		hasSections := !isRadio && slices.ContainsFunc(m.providerLists, func(p playlist.PlaylistInfo) bool {
			return p.Section != ""
		})

		if isRadio {
			for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget {
				scroll++
			}
		} else if m.provCursor >= scroll+visibleBudget {
			scroll = m.provCursor - visibleBudget + 1
		}

		prevPrefix := ""
		if isRadio && scroll > 0 {
			prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID)
		}
		prevSection := ""
		if hasSections && scroll > 0 {
			prevSection = m.providerLists[scroll-1].Section
		}

		for j := scroll; j < len(m.providerLists) && len(lines) < visibleBudget; j++ {
			p := m.providerLists[j]

			if isRadio {
				pfx := sl.IDPrefix(p.ID)
				if pfx != prevPrefix {
					var header string
					switch pfx {
					case "f":
						header = labeledSeparator("  ", "Favorites")
					case "c":
						header = labeledSeparator("  ", "Catalog")
					case "s":
						header = labeledSeparator("  ", "Search Results")
					}
					if header != "" && len(lines) < visibleBudget {
						lines = append(lines, dimStyle.Render(header))
					}
					prevPrefix = pfx
				}
			} else if hasSections && p.Section != prevSection {
				header := labeledSeparator("  ", p.Section)
				if len(lines) < visibleBudget {
					lines = append(lines, dimStyle.Render(header))
				}
				prevSection = p.Section
			}

			if len(lines) >= visibleBudget {
				break
			}

			prefix, style := m.providerRowStyle(p, j == m.provCursor)
			lines = append(lines, style.Render(playlistLabel(prefix, p)))
		}
	}

	// Loading indicator for catalog batch (never displace selected row if full).
	if isRadio && m.catalogBatch.loading && len(lines) < visibleBudget {
		lines = append(lines, loadingLine("Loading more stations…"))
	}

	return strings.Join(fitLines(lines, visibleBudget), "\n")
}

func (m Model) renderPlaylist() string {
	budget := m.effectivePlaylistVisible()
	if budget <= 0 {
		return ""
	}

	if m.focus == focusProvider {
		return m.renderProviderList()
	}

	tracks := m.playlist.Tracks()
	if len(tracks) == 0 {
		var lines []string
		if m.feedLoading {
			lines = append(lines, loadingLine("Loading feed…"))
		} else {
			lines = append(lines, dimStyle.Render("  No tracks loaded"))
		}
		return strings.Join(fitLines(lines, budget), "\n")
	}

	currentIdx := m.playlist.Index()
	scroll := m.playlistScroll(budget)

	lines := make([]string, 0, budget)
	numWidth := len(fmt.Sprintf("%d", len(tracks)))

	for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
		if row.Index < 0 {
			if len(lines)+1 >= budget {
				break
			}
			lines = append(lines, m.albumSeparator(row.Album, row.Year))
			continue
		}

		if len(lines) >= budget {
			break
		}

		i, t := row.Index, row.Track
		prefix := "  "
		style := playlistItemStyle

		if i == currentIdx && m.player.IsPlaying() {
			prefix = "▶ "
			style = playlistActiveStyle
		} else if strings.HasPrefix(t.Path, "ssh://") {
			prefix = "↗ "
		}

		if m.focus == focusPlaylist && i == m.plCursor {
			style = playlistSelectedStyle
		}

		if t.Unplayable {
			if m.focus == focusPlaylist && i == m.plCursor {
				style = dimStyle
			} else {
				style = playlistUnavailableStyle
			}
		}

		name := t.DisplayName()
		isBookmark := t.Bookmark
		bookmarkBudget := 0
		if isBookmark {
			bookmarkBudget = 2 // "★ "
		}
		queueSuffix := ""
		if qp := m.playlist.QueuePosition(i); qp > 0 {
			queueSuffix = fmt.Sprintf(" [Q%d]", qp)
		}
		queueLen := utf8.RuneCountInString(queueSuffix)

		linePrefixWidth := utf8.RuneCountInString(prefix) + numWidth + 2 // 2 for ". "

		// Truncate the track name only against queue/bookmark overhead, never album.
		name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen-bookmarkBudget)
		// Truncate the album to fit whatever space remains after the track name.
		albumSuffix := ""
		nameLen := utf8.RuneCountInString(name)
		if t.Unplayable {
			remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen
			if remaining >= 4 {
				albumSuffix = truncate(" (unavailable)", remaining)
			}
		} else if album := t.Album; album != "" && !m.showAlbumHeaders {
			remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen - 3 // 3 = " · "
			if remaining >= 4 {
				albumSuffix = " · " + truncate(album, remaining)
			}
		}

		numStr := fmt.Sprintf("%s%*d. ", prefix, numWidth, i+1)
		line := style.Render(numStr)
		if isBookmark {
			line += activeToggle.Render("★ ")
		}
		line += style.Render(name)
		if albumSuffix != "" {
			line += dimStyle.Render(albumSuffix)
		}
		if queueSuffix != "" {
			line += activeToggle.Render(queueSuffix)
		}
		lines = append(lines, line)
	}

	return strings.Join(padLines(lines, budget, len(lines)), "\n")
}

func (m Model) renderHelp() string {
	if ov, ok := m.activeOverlay(); ok {
		return fitHelpLine(ov.help(&m))
	}
	if m.focus == focusProvider {
		help := helpKey("↓↑", "Scroll ") + helpKey("Enter", "Load ") + helpKey("/", "Search ")
		if _, ok := m.provider.(provider.FavoriteToggler); ok {
			help += helpKey("f", "Fav ")
		}
		return help + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
	}
	if m.focus == focusProvPill {
		return helpKey("←→", "Select ") + helpKey("Enter", "Open ") + helpKey("Esc", "Back ") + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
	}

	// Show only the 4-5 most relevant keys per mode; Ctrl+K always anchored for full list.
	var hints []helpHint

	if m.focus == focusSpeed {
		hints = append(hints,
			helpHint{helpKey("←→", "Speed "), 100},
			helpHint{helpKey("[]", "Speed "), 90},
			helpHint{helpKey("Spc", "▶❚❚ "), 80},
			helpHint{helpKey("Tab", "Focus "), 70},
			helpHint{helpKey("Ctrl+K", "Keys"), 100},
		)
	} else if m.focus == focusEQ {
		hints = append(hints,
			helpHint{helpKey("←→", "Band "), 100},
			helpHint{helpKey("↓↑", "Gain "), 100},
			helpHint{helpKey("e", "Preset "), 90},
			helpHint{helpKey("Spc", "▶❚❚ "), 80},
			helpHint{helpKey("Tab", "Focus "), 70},
			helpHint{helpKey("Ctrl+K", "Keys"), 100},
		)
	} else {
		// focusPlaylist (default)
		hints = append(hints,
			helpHint{helpKey("↓↑", "Scroll "), 100},
			helpHint{helpKey("Enter", "Play "), 100},
			helpHint{helpKey("Spc", "▶❚❚ "), 90},
		)
		track, _ := m.playlist.Current()
		if !track.Stream || m.player.Seekable() {
			hints = append(hints, helpHint{helpKey("←→", "Seek "), 80})
		}
		if m.loadedPlaylist != "" {
			hints = append(hints, helpHint{helpKey("f", "Bookmark "), 75})
		}
		hints = append(hints,
			helpHint{helpKey("Tab", "Focus "), 70},
			helpHint{helpKey("Ctrl+K", "Keys"), 100},
		)
	}

	return fitHints(hints, ui.PanelWidth)
}

// helpHint is a rendered help key with an associated display priority.
type helpHint struct {
	text     string
	priority int
}

// fitHints drops lowest-priority hints until they fit within maxWidth.
// Widths are pre-computed once to avoid repeated lipgloss.Width calls.
func fitHints(hints []helpHint, maxWidth int) string {
	active := make([]bool, len(hints))
	widths := make([]int, len(hints))
	var total int
	for i, h := range hints {
		active[i] = true
		widths[i] = lipgloss.Width(h.text)
		total += widths[i]
	}

	for total > maxWidth {
		// Find lowest-priority active hint and drop it.
		minPri := math.MaxInt
		minIdx := -1
		for i, h := range hints {
			if active[i] && h.priority < minPri {
				minPri = h.priority
				minIdx = i
			}
		}
		if minIdx < 0 {
			break
		}
		active[minIdx] = false
		total -= widths[minIdx]
	}

	var sb strings.Builder
	for i, h := range hints {
		if active[i] {
			sb.WriteString(h.text)
		}
	}
	return sb.String()
}

// renderBottomStatus renders the bottom status line: speed (left) and
// network stats (right) on the same row.
func (m Model) renderBottomStatus() string {
	// Left: speed indicator.
	speed := m.player.Speed()
	if speed == 0 {
		speed = 1.0
	}
	speedVal := fmt.Sprintf("%.2gx", speed)

	var left string
	speedLabel := labelStyle.Render("SPD ")
	if m.focus == focusSpeed {
		speedLabel = activeToggle.Render("SPD ▸ ")
		left = speedLabel + activeToggle.Render("["+speedVal+"]")
	} else if speed != 1.0 {
		left = speedLabel + activeToggle.Render("["+speedVal+"]")
	} else {
		left = speedLabel + dimStyle.Render("[") + trackStyle.Render(speedVal) + dimStyle.Render("]")
	}

	// Right: network stream stats (empty for local files).
	var right string
	downloaded, total := m.player.StreamBytes()
	if downloaded > 0 || total > 0 {
		mb := float64(downloaded) / (1024 * 1024)
		if total > 0 {
			totalMB := float64(total) / (1024 * 1024)
			pct := float64(downloaded) / float64(total) * 100
			right = fmt.Sprintf("↓ %.1f / %.1f MB (%.0f%%)", mb, totalMB, pct)
		} else {
			right = fmt.Sprintf("↓ %.1f MB", mb)
		}
		if m.network.speed > 0 {
			kbs := m.network.speed / 1024
			if kbs >= 1024 {
				right += fmt.Sprintf("  %.1f MB/s", kbs/1024)
			} else {
				right += fmt.Sprintf("  %.0f KB/s", kbs)
			}
		}
		right = dimStyle.Render(right)
	}

	leftW := lipgloss.Width(left)
	rightW := lipgloss.Width(right)
	gap := max(1, ui.PanelWidth-leftW-rightW)

	if right == "" {
		return left
	}
	return left + strings.Repeat(" ", gap) + right
}
