package model

import (
	"errors"
	"fmt"
	"strings"

	"github.com/bjarneo/cliamp/lyrics"
	"github.com/bjarneo/cliamp/playlist"
	"github.com/bjarneo/cliamp/theme"
	"github.com/bjarneo/cliamp/ui"
)

// Inline overlays render in the playlist region while the now-playing,
// visualizer, and controls chrome stays live above them. Each overlay supplies
// three pieces, all the same vertical size as the normal playlist chrome so
// opening an overlay never shifts the layout height:
//
//   - a header line   (via overlayHeaderLine, used by renderPlaylistHeader)
//   - a body          (via overlayBody, fills effectivePlaylistVisible rows)
//   - a help line      (via overlayHelpLine, used by renderHelp)
//
// The switches below are ordered to match activeScreen so the header, body, and
// help always describe the same overlay.

// — shared header/body helpers —

// sepHeader renders a labeled separator. The label is embedded before the "─"
// fill, so separatorLine truncates it to the panel width: it never wraps.
func sepHeader(label string) string {
	return dimStyle.Render(labeledSeparator("", label))
}

// sepHeaderN appends an "n/total" position counter to a separator label.
func sepHeaderN(label string, pos, total int) string {
	if total <= 0 {
		return sepHeader(label)
	}
	return sepHeader(fmt.Sprintf("%s  %d/%d", label, pos, total))
}

// promptHeader renders an editable input with the shared editor cursor at its
// actual insertion point, then clips it to the panel width.
func (m Model) promptHeader(field, label, value string) string {
	return playlistSelectedStyle.Render(truncate("  "+label+": "+m.textWithCursor(field, value), ui.PanelWidth))
}

// filterPromptHeader renders a `/` filter input as the header line.
func (m Model) filterPromptHeader(field, query string) string {
	return playlistSelectedStyle.Render(truncate("  / "+m.textWithCursor(field, query), ui.PanelWidth))
}

// filterCountHeader renders a `/` filter prompt with a trailing match count,
// kept to one panel-wide row by clipping the query to leave room for the count.
func (m Model) filterCountHeader(field, query, count string) string {
	maxPrompt := max(1, ui.PanelWidth-len(count)-2)
	return playlistSelectedStyle.Render(truncate("  / "+m.textWithCursor(field, query), maxPrompt)) + dimStyle.Render("  "+count)
}

// windowList renders items[scroll:] into at most budget rows, applying the
// cursor highlight via cursorLine.
func windowList(items []string, cursor, scroll, budget int) string {
	if budget <= 0 {
		return ""
	}
	lines := make([]string, 0, budget)
	for i := scroll; i < len(items) && len(lines) < budget; i++ {
		lines = append(lines, cursorLine(items[i], i == cursor))
	}
	return strings.Join(padLines(lines, budget, len(lines)), "\n")
}

// bodyLines fits pre-built lines into the budget (truncate + pad to budget).
func bodyLines(lines []string, budget int) string {
	if budget <= 0 {
		return ""
	}
	return strings.Join(fitLines(lines, budget), "\n")
}

// bodyMessage renders a single dim message line into the budget.
func bodyMessage(msg string, budget int) string {
	return bodyLines([]string{dimStyle.Render("  " + msg)}, budget)
}

// renderTrackRowsBody renders a track list with album-header separators into
// the playlist-region budget, highlighting the row at cursor. Shared by the
// nav browser and playlist manager unfiltered track views.
func (m Model) renderTrackRowsBody(tracks []playlist.Track, cursor, scroll, budget int) string {
	lines := make([]string, 0, budget)
	for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
		if len(lines) >= budget {
			break
		}
		if row.Index < 0 {
			lines = append(lines, m.albumSeparator(row.Album, row.Year))
			continue
		}
		i, t := row.Index, row.Track
		label := formatTrackRow(i+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs)
		lines = append(lines, cursorLine(label, i == cursor))
	}
	return bodyLines(lines, budget)
}

// — dispatch —

// overlayView bundles the three render pieces of an inline overlay: the header
// line (shown where the playlist header is), the help line, and the body that
// fills the playlist region. The pieces are method expressions (func(*Model)),
// not bound method values, so building an overlayView does not copy the Model
// onto the heap on the render hot path.
type overlayView struct {
	header func(*Model) string
	help   func(*Model) string
	body   func(*Model) string
}

// activeOverlay returns the render pieces for the active inline overlay, or
// ok=false when no overlay is open (the normal playlist is shown). Describing
// each overlay once here keeps its header, help, and body in sync, and the
// cases are ordered to match activeScreen. renderPlaylistHeader, renderHelp,
// and renderMainBody each call this and invoke the piece they need with &m.
func (m Model) activeOverlay() (overlayView, bool) {
	switch {
	case m.keymap.visible:
		return overlayView{(*Model).keymapHeaderLine, (*Model).keymapHelpLine, (*Model).renderKeymapList}, true
	case m.devicePicker.visible:
		return overlayView{(*Model).deviceHeaderLine, (*Model).devicePickerHelpLine, (*Model).renderDeviceBody}, true
	case m.plPicker.visible:
		return overlayView{(*Model).plPickerHeaderLine, (*Model).plPickerHelpLine, (*Model).renderPlaylistPickerBody}, true
	case m.fileBrowser.visible:
		return overlayView{(*Model).fbHeaderLine, (*Model).fbHelpLine, (*Model).renderFileBrowserBody}, true
	case m.spotSearch.visible:
		return overlayView{(*Model).spotSearchHeaderLine, (*Model).spotSearchHelpLine, (*Model).renderSpotSearchBody}, true
	case m.navBrowser.visible:
		return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true
	case m.themePicker.visible:
		return overlayView{(*Model).themePickerHeaderLine, (*Model).themePickerHelpLine, (*Model).renderThemeBody}, true
	case m.visPicker.visible:
		return overlayView{(*Model).visPickerHeaderLine, (*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true
	case m.plManager.visible:
		return overlayView{(*Model).plMgrHeaderLine, (*Model).plMgrHelpLine, (*Model).renderPlMgrBody}, true
	case m.queue.visible:
		return overlayView{
			func(m *Model) string { return sepHeaderN("Queue", m.queue.cursor+1, m.playlist.QueueLen()) },
			(*Model).queueHelpLine, (*Model).renderQueueBody}, true
	case m.showInfo:
		return overlayView{
			func(*Model) string { return sepHeader("Track Info") },
			func(m *Model) string { return m.commandHelp(commandModeInfo) },
			(*Model).renderInfoBody}, true
	case m.lyrics.visible:
		return overlayView{
			func(*Model) string { return sepHeader("Lyrics") },
			(*Model).lyricsHelpLine, (*Model).renderLyricsBody}, true
	case m.jumping:
		return overlayView{
			func(*Model) string { return sepHeader("Jump to Time") },
			func(m *Model) string { return m.commandHelp(commandModeJump) },
			(*Model).renderJumpBody}, true
	case m.urlInputting:
		return overlayView{
			func(m *Model) string { return m.promptHeader("url", "Load URL", m.urlInput) },
			func(m *Model) string { return m.commandHelp(commandModeURL) },
			(*Model).renderURLBody}, true
	case m.search.active:
		return overlayView{(*Model).searchHeaderLine, (*Model).searchHelpLine, (*Model).renderSearchList}, true
	case m.netSearch.active:
		return overlayView{(*Model).netSearchHeaderLine, (*Model).netSearchHelpLine, (*Model).renderNetSearchBody}, true
	}
	return overlayView{}, false
}

// renderMainBody returns the active overlay's body, or the playlist when no
// overlay is open.
func (m Model) renderMainBody() string {
	if ov, ok := m.activeOverlay(); ok {
		return ov.body(&m)
	}
	return m.renderPlaylist()
}

// — search —

func (m Model) searchHeaderLine() string {
	return m.filterCountHeader("playlist-search", m.search.query, m.formatListMatchCount(len(m.search.results), m.playlist.Len()))
}

// — theme picker —

func (m Model) themeCount() int { return len(m.themes) + 1 }

func (m Model) themePickerHeaderLine() string {
	if m.themePicker.filtering || m.themePicker.filter != "" {
		return m.filterCountHeader("theme-picker-filter", m.themePicker.filter, fmt.Sprintf("%d/%d", m.themePickerViewCount(), m.themeCount()))
	}
	return sepHeaderN("Themes", m.themePicker.cursor+1, m.themePickerViewCount())
}

func (m Model) renderThemeBody() string {
	budget := m.effectivePlaylistVisible()
	items := make([]string, 0, m.themeCount())
	items = append(items, theme.DefaultName)
	for _, t := range m.themes {
		items = append(items, t.Name)
	}
	if m.themePicker.filter != "" {
		filtered := make([]string, 0, len(m.themePicker.filtered))
		for _, rawIdx := range m.themePicker.filtered {
			if rawIdx >= 0 && rawIdx < len(items) {
				filtered = append(filtered, items[rawIdx])
			}
		}
		items = filtered
	}
	if len(items) == 0 {
		return bodyMessage("No matches.", budget)
	}
	return windowList(items, m.themePicker.cursor, m.themePicker.scroll, budget)
}

// — device picker —

func (m Model) deviceHeaderLine() string {
	if m.devicePicker.loading {
		return sepHeader("Audio Devices")
	}
	return sepHeaderN("Audio Devices", m.devicePicker.cursor+1, len(m.devicePicker.devices))
}

func (m Model) renderDeviceBody() string {
	budget := m.effectivePlaylistVisible()
	if m.devicePicker.loading {
		return bodyLines([]string{loadingLine("Loading devices…")}, budget)
	}
	if len(m.devicePicker.devices) == 0 {
		return bodyMessage("No audio output devices found.", budget)
	}
	items := make([]string, len(m.devicePicker.devices))
	for i, d := range m.devicePicker.devices {
		label := d.Description
		if label == "" {
			label = d.Name
		}
		if d.Active {
			label += " " + activeToggle.Render("●")
		}
		items[i] = label
	}
	return windowList(items, m.devicePicker.cursor, m.devicePicker.scroll, budget)
}

// — queue —

func (m Model) renderQueueBody() string {
	budget := m.effectivePlaylistVisible()
	tracks := m.playlist.QueueTracks()
	if len(tracks) == 0 {
		return bodyMessage("(empty)", budget)
	}
	items := make([]string, len(tracks))
	for i, t := range tracks {
		items[i] = fmt.Sprintf("%d. %s", i+1, truncate(t.DisplayName(), ui.PanelWidth-8))
	}
	return windowList(items, m.queue.cursor, m.queue.scroll, budget)
}

// — track info —

func (m Model) renderInfoBody() string {
	budget := m.effectivePlaylistVisible()
	lines := m.infoLines()
	start := min(m.infoScroll, max(0, len(lines)-budget))
	end := min(start+budget, len(lines))
	return bodyLines(lines[start:end], budget)
}

func (m Model) infoLines() []string {
	track, _ := m.currentPlaybackTrack()

	var lines []string
	field := func(label, value string) {
		if value != "" {
			lines = append(lines, dimStyle.Render("  "+label+": ")+trackStyle.Render(value))
		}
	}
	field("Title", track.Title)
	field("Artist", track.Artist)
	field("Album", track.Album)
	field("Genre", track.Genre)
	if track.Year != 0 {
		field("Year", fmt.Sprintf("%d", track.Year))
	}
	if track.TrackNumber != 0 {
		field("Track", fmt.Sprintf("%d", track.TrackNumber))
	}
	field("Path", track.Path)
	if len(lines) == 0 {
		lines = append(lines, dimStyle.Render("  No track metadata available."))
	}
	return lines
}

func (m *Model) infoMaybeAdjustScroll() {
	m.infoScroll = min(m.infoScroll, max(0, len(m.infoLines())-m.effectivePlaylistVisible()))
}

// — URL input —

func (m Model) renderURLBody() string {
	budget := m.effectivePlaylistVisible()
	lines := []string{dimStyle.Render("  Paste a stream, track, or playlist URL above.")}
	if m.urlErr != "" {
		lines = append(lines, errorStyle.Render("  "+m.urlErr))
	}
	return bodyLines(lines, budget)
}

// — jump to time —

func (m Model) renderJumpBody() string {
	budget := m.effectivePlaylistVisible()
	pos := m.player.Position()
	dur := m.player.Duration()
	inputLine := dimStyle.Faint(true).Render("  " + formatJumpPlaceholder(dur))
	if m.jumpInput != "" {
		inputLine = playlistSelectedStyle.Render("  " + m.textWithCursor("jump", m.jumpInput))
	}
	lines := []string{
		dimStyle.Render(fmt.Sprintf("  %s / %s", formatJumpClock(pos), formatJumpClock(dur))),
		"",
		inputLine,
	}
	if m.jumpErr != "" {
		lines = append(lines, errorStyle.Render("  "+m.jumpErr))
	}
	return bodyLines(lines, budget)
}

// — lyrics —

func (m Model) lyricsHelpLine() string {
	return m.commandHelp(commandModeLyrics)
}

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

	var lines []string
	switch {
	case m.lyrics.loading:
		lines = append(lines, dimStyle.Render("  Searching for lyrics..."))
	case m.lyrics.err != nil:
		if errors.Is(m.lyrics.err, lyrics.ErrNotFound) {
			lines = append(lines, dimStyle.Render("  No lyrics found for this track."))
		} else {
			lines = append(lines, helpStyle.Render("  Lyrics fetch failed: "+m.lyrics.err.Error()))
		}
	case len(m.lyrics.lines) == 0:
		artist, title := m.lyricsArtistTitle()
		if artist == "" && title == "" {
			lines = append(lines, dimStyle.Render("  No artist/title metadata available."))
			if track, idx := m.currentPlaybackTrack(); idx >= 0 && track.Stream {
				lines = append(lines, dimStyle.Render("  Waiting for stream metadata..."))
			}
		} else {
			lines = append(lines, dimStyle.Render("  No lyrics loaded. Press r to retry."))
		}
	case m.lyricsSyncable() && m.lyricsHaveTimestamps():
		pos := m.player.Position()
		activeIdx := -1
		for i, line := range m.lyrics.lines {
			if line.Start <= pos {
				activeIdx = i
			} else {
				break
			}
		}
		half := visible / 2
		startIdx := max(activeIdx-half, 0)
		endIdx := startIdx + visible
		if endIdx > len(m.lyrics.lines) {
			endIdx = len(m.lyrics.lines)
			startIdx = max(endIdx-visible, 0)
		}
		for i := startIdx; i < endIdx; i++ {
			text := m.lyrics.lines[i].Text
			if text == "" {
				text = "♪"
			}
			if i == activeIdx {
				lines = append(lines, playlistSelectedStyle.Render("  "+text))
			} else {
				lines = append(lines, dimStyle.Render("  "+text))
			}
		}
	default:
		endIdx := min(m.lyrics.scroll+visible, len(m.lyrics.lines))
		for i := m.lyrics.scroll; i < endIdx; i++ {
			text := m.lyrics.lines[i].Text
			if text == "" {
				text = "♪"
			}
			lines = append(lines, dimStyle.Render("  "+text))
		}
	}
	return bodyLines(lines, visible)
}

// — online (net) search —

func (m Model) netSearchSource() string {
	if m.netSearch.soundcloud {
		return "SoundCloud"
	}
	return "YouTube"
}

func (m Model) netSearchHeaderLine() string {
	if m.netSearch.screen == netSearchResults {
		return sepHeaderN("Online Results", m.netSearch.cursor+1, len(m.netSearch.results))
	}
	return m.promptHeader("net-search", m.netSearchSource()+" search", m.netSearch.query)
}

func (m Model) netSearchHelpLine() string {
	if m.netSearch.screen == netSearchResults {
		return m.netSearchResultsHelpLine()
	}
	return m.commandHelp(commandModeNetSearch)
}

func (m Model) renderNetSearchBody() string {
	budget := m.effectivePlaylistVisible()
	if m.netSearch.screen == netSearchInput {
		var lines []string
		if m.netSearch.loading {
			lines = append(lines, dimStyle.Render("  Searching "+m.netSearchSource()+"..."))
		} else {
			lines = append(lines, dimStyle.Render("  Type a query and press Enter to search "+m.netSearchSource()+"."))
		}
		if m.netSearch.err != "" {
			lines = append(lines, "", helpStyle.Render("  "+m.netSearch.err))
		}
		return bodyLines(lines, budget)
	}

	if len(m.netSearch.results) == 0 {
		return bodyMessage("No results", budget)
	}
	items := make([]string, len(m.netSearch.results))
	for i, t := range m.netSearch.results {
		items[i] = truncate(t.DisplayName(), ui.PanelWidth-8)
	}
	return windowList(items, m.netSearch.cursor, m.netSearch.scroll, budget)
}

// — provider (Spotify) search —

func (m Model) spotSearchHeaderLine() string {
	switch m.spotSearch.screen {
	case spotSearchResults:
		return sepHeaderN("Results", m.spotSearch.cursor+1, len(m.spotSearch.results))
	case spotSearchPlaylist:
		return sepHeaderN("Add to Playlist", m.spotSearch.cursor+1, len(m.spotSearch.playlists)+1)
	case spotSearchNewName:
		return m.promptHeader("spot-playlist-name", "New Playlist", m.spotSearch.newName)
	default:
		return m.promptHeader("spot-search", "Search", m.spotSearch.query)
	}
}

func (m Model) spotSearchHelpLine() string {
	switch m.spotSearch.screen {
	case spotSearchResults:
		return m.spotSearchResultsHelpLine()
	case spotSearchPlaylist:
		return m.spotSearchPlaylistHelpLine()
	case spotSearchNewName:
		return m.commandHelp(commandModeSpotSearch)
	default:
		return m.commandHelp(commandModeSpotSearch)
	}
}

func (m Model) renderSpotSearchBody() string {
	budget := m.effectivePlaylistVisible()
	var body string
	switch m.spotSearch.screen {
	case spotSearchResults:
		if len(m.spotSearch.results) == 0 {
			body = bodyMessage("No results", budget)
		} else {
			items := make([]string, len(m.spotSearch.results))
			for i, t := range m.spotSearch.results {
				items[i] = truncate(fmt.Sprintf("%s - %s", t.Artist, t.Title), ui.PanelWidth-8)
			}
			body = windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, budget)
		}
	case spotSearchPlaylist:
		if m.spotSearch.loading {
			body = bodyLines([]string{loadingLine("Loading playlists…")}, budget)
			break
		}
		track := m.spotSearch.selTrack
		head := dimStyle.Render("  " + truncate(fmt.Sprintf("%s - %s", track.Artist, track.Title), ui.PanelWidth-2))
		count := len(m.spotSearch.playlists) + 1
		items := make([]string, count)
		for i := range count {
			if i < len(m.spotSearch.playlists) {
				items[i] = m.spotSearch.playlists[i].Name
			} else {
				items[i] = "+ New Playlist..."
			}
		}
		list := windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, max(0, budget-1))
		body = strings.Join([]string{head, list}, "\n")
	case spotSearchNewName:
		body = bodyMessage("Enter a name for the new playlist above.", budget)
	default:
		var lines []string
		if m.spotSearch.loading {
			lines = append(lines, dimStyle.Render("  Searching..."))
		} else {
			lines = append(lines, dimStyle.Render("  Type a query and press Enter to search."))
		}
		body = bodyLines(lines, budget)
	}
	if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist {
		return strings.Join([]string{body, helpStyle.Render("  " + m.spotSearch.err)}, "\n")
	}
	return body
}
