package app

import (
	"context"
	"errors"
	"fmt"
	"image/color"
	"math/rand/v2"
	"os"
	"slices"
	"strings"
	"time"
	"unicode/utf8"

	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/sahilm/fuzzy"
	"github.com/samber/lo"
	"golang.org/x/term"

	"tobi/internal/remote"
	"tobi/internal/session"
)

const (
	maxUIWidth = 76
	startWidth = 80
)

// the picker's palette: catppuccin latte for light terminals, frappé for dark
// ones. it starts dark and is rebuilt once the terminal answers the
// background query the program issues on startup.
type theme struct {
	text, dim, faint, mint, yellow, salmon, mauve, lavender lipgloss.Style
	pill, pick, plain, border                               lipgloss.Style
}

func newTheme(dark bool) theme {
	ld := lipgloss.LightDark(dark)
	c := func(l, d string) color.Color { return ld(lipgloss.Color(l), lipgloss.Color(d)) }
	fg := func(l, d string) lipgloss.Style { return lipgloss.NewStyle().Foreground(c(l, d)) }
	t := theme{
		text:     fg("#4c4f69", "#cdd6f4"),
		dim:      fg("#9ca0b0", "#6c7086"),
		lavender: fg("#7287fd", "#b4befe"),
		mauve:    fg("#8839ef", "#cba6f7"),
		mint:     fg("#40a02b", "#a6e3a1"),
		yellow:   fg("#df8e1d", "#f9e2af"),
		salmon:   fg("#d20f39", "#f38ba8"),
		pill:     lipgloss.NewStyle().Background(c("#7287fd", "#b4befe")).Foreground(c("#eff1f5", "#1e1e2e")).Padding(0, 1),
		pick:     lipgloss.NewStyle().Background(c("#ccd0da", "#313244")),
		plain:    lipgloss.NewStyle(),
	}
	t.faint, t.border = t.dim.Faint(true), t.dim
	return t
}

// setTheme restyles the picker and its filter for a light or dark terminal
func (m *pickerModel) setTheme(dark bool) {
	m.th = newTheme(dark)
	st := textinput.DefaultStyles(dark)
	st.Focused.Placeholder, st.Blurred.Placeholder = m.th.faint, m.th.faint
	m.filter.SetStyles(st)
}

type hostRow struct {
	name        string
	local       bool
	probing     bool
	peerOffline bool // tailscale says the host is off the mesh
	unreachable bool // could not reach the host over ssh at all
	started     time.Time
	duration    time.Duration
	sessions    []session.Session
}

type probeResult struct {
	row      int
	sessions []session.Session
	err      error
	duration time.Duration
}

type pickerModel struct {
	filter    textinput.Model
	rows      []hostRow
	cursor    int
	width     int
	height    int // 0 until the first resize, meaning no known limit
	th        theme
	spinner   spinner.Model
	twitch    int
	blinking  bool
	blinkAt   int // tick the current blink started
	nextBlink int
	lookDir   int // -1 left, +1 right; persists after the blink
	holdTicks int // how long the gaze-hold lasts after a blink
	probes    chan probeResult
	picked    *pickEntry
	killing   map[string]bool // keyed by canonical name, in flight
	notice    string          // the last kill failure, until the next key
}

type killResult struct {
	sess session.Session
	err  error
}

type pickEntry struct {
	host string
	sess *session.Session // nil when the line is the host row itself
}

func (m pickerModel) Init() tea.Cmd {
	return tea.Batch(m.spinner.Tick, waitForProbe(m.probes), tea.RequestBackgroundColor)
}

func waitForProbe(probes chan probeResult) tea.Cmd { return func() tea.Msg { return <-probes } }

func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width, m.height = msg.Width, msg.Height
		m.filter.SetWidth(min(msg.Width, maxUIWidth) - 8)
		m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width

	case tea.BackgroundColorMsg:
		m.setTheme(msg.IsDark())

	case probeResult:
		byNewest(msg.sessions)
		r := &m.rows[msg.row]
		r.probing, r.unreachable, r.duration, r.sessions = false, errors.Is(msg.err, remote.ErrUnreachable), msg.duration, msg.sessions
		m.move(0)
		if lo.SomeBy(m.rows, func(row hostRow) bool { return row.probing }) {
			return m, waitForProbe(m.probes)
		}
		return m, nil

	case spinner.TickMsg:
		m.twitch++
		if m.filter.Value() == "" {
			if !m.blinking && m.twitch >= m.nextBlink {
				m.blinking, m.blinkAt = true, m.twitch
				if rand.IntN(2) == 0 {
					m.lookDir = -m.lookDir // a chance to look the other way
				}
				m.holdTicks = 20 + rand.IntN(21)
			}
			if m.blinking && m.twitch-m.blinkAt >= 18+m.holdTicks {
				m.blinking = false
				m.nextBlink = m.twitch + 30 + rand.IntN(31)
			}
		}
		var cmd tea.Cmd
		m.spinner, cmd = m.spinner.Update(msg)
		return m, cmd

	case killResult:
		c := msg.sess.Canonical()
		m.killing = lo.OmitByKeys(m.killing, []string{c})
		if msg.err != nil {
			m.notice = strings.Join(strings.Fields(msg.err.Error()), " ")
			return m, nil
		}
		for i := range m.rows {
			m.rows[i].sessions = lo.Reject(m.rows[i].sessions, func(s session.Session, _ int) bool { return s.Canonical() == c })
		}
		m.move(0)
		return m, nil

	case tea.KeyPressMsg:
		m.notice = ""
		switch msg.String() {
		case "ctrl+c":
			return m, tea.Quit
		case "ctrl+x":
			return m.kill()
		case "up":
			m.move(-1)
			return m, nil
		case "down":
			m.move(1)
			return m, nil
		case "enter":
			if p := m.pick(); p != nil {
				m.picked = p
				return m, tea.Quit
			}
			return m, nil
		case "esc":
			if m.filter.Value() == "" {
				return m, tea.Quit
			}
			m.filter.SetValue("")
			m.cursor = 0
		}
		var cmd tea.Cmd
		before := m.filter.Value()
		m.filter, cmd = m.filter.Update(msg)
		if m.filter.Value() != before {
			m.cursor = 0
		}
		return m, cmd
	}
	return m, nil
}

func (m pickerModel) kill() (tea.Model, tea.Cmd) {
	p := m.pick()
	if p == nil || p.sess == nil || m.killing[p.sess.Canonical()] {
		return m, nil
	}
	s := *p.sess
	m.killing = lo.Assign(m.killing, map[string]bool{s.Canonical(): true})
	return m, func() tea.Msg {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		return killResult{s, Kill(ctx, s)}
	}
}

func (m pickerModel) rowsFor() []hostRow {
	q := strings.ToLower(strings.TrimSpace(m.filter.Value()))
	if q == "" {
		return m.rows
	}
	var out []hostRow
	for _, r := range m.rows {
		if len(fuzzy.Find(q, []string{strings.ToLower(r.name)})) > 0 {
			out = append(out, r) // a host that matches keeps all of its sessions
		} else if hits := rankSessions(r.sessions, q, false); len(hits) > 0 {
			r.sessions = hits
			out = append(out, r)
		}
	}
	return out
}

// in draw order; hosts we cannot reach are drawn but not selectable
func (m pickerModel) entries() []pickEntry {
	var out []pickEntry
	for _, r := range m.rowsFor() {
		if selectableHost(r) {
			out = append(out, pickEntry{host: r.name})
		}
		for _, s := range r.sessions {
			out = append(out, pickEntry{host: r.name, sess: &s})
		}
	}
	return out
}

func selectableHost(r hostRow) bool { return !r.peerOffline && !r.unreachable }

// move parks the cursor at zero when a filter left nothing to pick
func (m *pickerModel) move(dir int) { m.cursor = max(0, min(len(m.entries())-1, m.cursor+dir)) }

func (m pickerModel) pick() *pickEntry {
	if e := m.entries(); m.cursor >= 0 && m.cursor < len(e) {
		return &e[m.cursor]
	}
	return nil
}

var kittyFace = [...]string{"(^% %  ^)", "(^ % % ^)", "(^  % %^)"}

const eyeDozing, eyeOpen, eyeLooking = "-", "•", "."

func makeKitty(dir int, char string) string {
	return strings.ReplaceAll(kittyFace[min(2, max(0, dir+1))], "%", char)
}

func (m pickerModel) View() tea.View { return tea.NewView(m.view()) }

func (m pickerModel) view() string {
	w := min(m.width, maxUIWidth)
	if w < 24 {
		return ""
	}
	var b strings.Builder

	counts := fmt.Sprintf("%d host%s · %d active ", len(m.rows), plural(len(m.rows)), lo.SumBy(m.rows, func(r hostRow) int { return len(r.sessions) }))
	kitty := makeKitty(m.lookDir, eyeDozing)
	if m.filter.Value() != "" {
		kitty = makeKitty(m.filter.Position()/10-1, eyeLooking)
	} else if m.blinking {
		if t := m.twitch - m.blinkAt; t < 10 || t >= 12 && t < 14 { // eyes open, straight
			kitty = makeKitty(m.lookDir, eyeOpen)
		}
	}
	title := m.th.mauve.Render(kitty+" ") + m.th.text.Bold(true).Render("tobi ")
	wt := utf8.RuneCountInString(kitty) + 6
	mid := strings.Repeat("─", max(1, w-5-wt))
	if wt+utf8.RuneCountInString(counts) <= w-6 {
		mid = strings.Repeat("─", w-6-wt-utf8.RuneCountInString(counts)) + " " + counts
	}
	b.WriteString("╭─ " + title + m.th.border.Render(mid+"─╮") + "\n")
	filter := m.filter.View()
	b.WriteString(m.th.border.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + m.th.border.Render(" │") + "\n")
	b.WriteString(m.th.border.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n")

	rows := m.rowsFor()
	if len(rows) == 0 {
		b.WriteString(" " + m.th.faint.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n")
	}
	m.drawHostTable(&b, w, rows)
	b.WriteString(m.th.border.Render(strings.Repeat("─", w)) + "\n")
	if m.notice != "" {
		b.WriteString(m.th.salmon.MaxWidth(w).Render(m.notice))
		return b.String()
	}
	hs := m.hints()
	span := func(hs []pickerHint) int { return lo.SumBy(hs, pickerHint.width) + 3*(len(hs)-1) }
	for len(hs) > 1 && span(hs) > w {
		hs = hs[:len(hs)-1]
	}
	free, gaps := w-lo.SumBy(hs, pickerHint.width), max(1, len(hs)-1)
	for i, h := range hs {
		if i > 0 {
			b.WriteString(strings.Repeat(" ", free/gaps+lo.Ternary(i <= free%gaps, 1, 0)))
		}
		b.WriteString(m.th.dim.Render("[") + m.th.text.Render(h.k) + m.th.dim.Render("] "+h.act))
	}
	return b.String()
}

type pickerHint struct{ k, act string }

func (h pickerHint) width() int { return utf8.RuneCountInString(h.k+h.act) + 3 } // the brackets and one gap

func (m pickerModel) hints() []pickerHint {
	hs := []pickerHint{{"enter", "attach"}, {"↑↓", "move"}, {"esc", "clear"}, {"ctrl+c", "quit"}}
	switch p := m.pick(); {
	case p == nil:
	case p.sess == nil:
		hs[0].act = "new shell"
	default:
		hs = slices.Insert(hs, 2, pickerHint{"ctrl+x", "kill"})
	}
	return hs
}

// the cursor row is one background run: nested colour resets would punch holes in it
func (t theme) writeLine(b *strings.Builder, w int, left, right string, sel bool) {
	gap := w - lipgloss.Width(left) - lipgloss.Width(right)
	if gap < 0 {
		gap = 1 // a row longer than the width still keeps its columns apart
	}
	line := left + strings.Repeat(" ", gap) + right
	if sel {
		line = t.pick.Render(line)
	}
	b.WriteString(line + "\n")
}

func (m pickerModel) sessionStatus(s session.Session, sel bool) string {
	if !m.killing[s.Canonical()] {
		return relTime(s.StartedAt)
	}
	return lo.Ternary(sel, "killing…", m.th.salmon.Render("killing…"))
}

func (m pickerModel) sessionRow(b *strings.Builder, w int, s session.Session, sel bool, right string) {
	pad, idSt, cmdSt, cwdSt := "     ", m.th.mauve, m.th.lavender, m.th.yellow
	if sel {
		pad, idSt, cmdSt, cwdSt = "  ›  ", m.th.plain, m.th.plain, m.th.plain
	}
	left := pad + idSt.Render(s.ID) + "  " + cmdSt.Render(s.Command) + "  " + cwdSt.Render(s.Cwd)
	m.th.writeLine(b, w, left, right, sel)
}

type hostLine struct {
	glyph, sess, ping               string
	glyphSt, sessSt, nameSt, pingSt lipgloss.Style
}

func (t theme) hostLine(r hostRow, spin string) hostLine {
	l := hostLine{
		glyph: "○", sess: "no sessions", ping: fmt.Sprintf("%.1fs", r.duration.Seconds()),
		glyphSt: t.dim, sessSt: t.dim, nameSt: t.text, pingSt: t.dim,
	}
	switch {
	case r.probing:
		l.sess = "searching"
		l.ping = fmt.Sprintf("%s %.1fs", spin, probeClock(time.Since(r.started)).Seconds())
	case r.unreachable:
		l.glyph, l.sess = "!", "unreachable"
		l.glyphSt, l.sessSt, l.nameSt, l.pingSt = t.salmon, t.salmon.Faint(true), t.faint, t.salmon.Faint(true)
	case r.peerOffline:
		l.glyph, l.sess, l.ping = "×", "offline", ""
		l.glyphSt, l.sessSt, l.nameSt = t.faint, t.faint, t.faint
	case len(r.sessions) > 0:
		l.glyph, l.sess = "●", fmt.Sprintf("%d session%s", len(r.sessions), plural(len(r.sessions)))
		l.glyphSt, l.sessSt = t.mint, t.mint.Faint(true)
	}
	return l
}

// the header, the filter box, the divider and the hints drawn around the table
const chromeLines = 5

// scrollWindow picks the avail of n lines to draw, centred on the cursor's
// line and clamped to the ends
func scrollWindow(n, at, avail int) (from, to int) {
	if n <= avail {
		return 0, n
	}
	from = max(0, min(n-avail, at-avail/2))
	return from, from + avail
}

// the table only draws the lines that fit: a taller view loses its top to
// the renderer, taking the filter box and the cursor with it
func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) {
	var t strings.Builder
	nameW := min(22, lo.Max(lo.Map(rows, func(r hostRow, _ int) int { return utf8.RuneCountInString(r.name) })))
	cursor, at := 0, 0 // the entries() index being drawn, and the table line holding the cursor
	for _, r := range rows {
		l := m.th.hostLine(r, m.spinner.View())
		reserve := 5 + lo.Ternary(r.local, 8, 0) // the local pill and its gap
		sessW := min(12, max(0, w-3-2-nameW-3-reserve))
		sel := selectableHost(r) && cursor == m.cursor
		if selectableHost(r) {
			cursor++
		}
		pad, right, nameSt, sessSt := " "+l.glyphSt.Width(2).Render(l.glyph), lo.Ternary(r.local, m.th.pill.Render("local"), l.pingSt.Render(l.ping)), l.nameSt, l.sessSt
		if sel {
			pad, right, nameSt, sessSt = " › ", lo.Ternary(r.local, " local ", l.ping), m.th.plain, m.th.plain
			at = strings.Count(t.String(), "\n")
		}
		m.th.writeLine(&t, w, pad+nameSt.Width(nameW).Render(r.name)+"   "+sessSt.Width(12).MaxWidth(sessW).Render(l.sess), right, sel)

		for _, s := range r.sessions {
			sel := cursor == m.cursor
			if sel {
				at = strings.Count(t.String(), "\n")
			}
			m.sessionRow(&t, w, s, sel, m.sessionStatus(s, sel))
			cursor++
		}
	}
	lines := strings.SplitAfter(t.String(), "\n")
	lines = lines[:len(lines)-1] // the empty tail after the last newline
	from, to := scrollWindow(len(lines), at, lo.Ternary(m.height > 0, max(1, m.height-chromeLines), len(lines)))
	b.WriteString(strings.Join(lines[from:to], ""))
}

func runPicker(local []session.Session, initial string) error {
	peersCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	peers := remote.DiscoverPeers(peersCtx) // never includes this host
	cancel()

	filter := textinput.New()
	filter.Prompt = "› "
	filter.Placeholder = "search sessions…"
	filter.Focus()
	filter.SetWidth(startWidth - 8)
	filter.SetValue(strings.TrimSpace(initial))
	m := pickerModel{
		filter:  filter,
		width:   startWidth,
		probes:  make(chan probeResult, len(peers)),
		spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
		rows:    []hostRow{{name: strings.ToLower(session.ShortHostname()), local: true, sessions: local}},
	}
	m.setTheme(true) // dark until the terminal answers the background query
	m.lookDir, m.nextBlink = []int{-1, 1}[rand.IntN(2)], 30+rand.IntN(31)
	online, offline := lo.FilterReject(peers, func(p remote.Peer, _ int) bool { return p.Online })
	for _, p := range online {
		for _, dest := range remote.ProbeDests(p.Name) {
			go probe(m.probes, len(m.rows), dest)
			m.rows = append(m.rows, hostRow{name: dest, probing: true, started: time.Now()})
		}
	}
	if os.Getenv("TOBI_OFFLINE") == "1" {
		for _, p := range offline {
			m.rows = append(m.rows, hostRow{name: p.Name, peerOffline: true})
		}
	}

	m.move(0)
	model, err := tea.NewProgram(m).Run()
	if err != nil {
		return err
	}
	fin := model.(pickerModel)
	switch p := fin.picked; {
	case p == nil:
		return nil
	case p.sess != nil:
		return connectOrHop(*p.sess)
	}
	fmt.Fprintln(os.Stderr, fin.th.dim.Render("starting a shell on "+fin.picked.host+"…"))
	return Spawn(fin.picked.host, loginShell)
}

func probe(probes chan<- probeResult, row int, dest string) {
	start := time.Now()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	list, err := remote.QueryRemote(ctx, dest)
	probes <- probeResult{row: row, sessions: list, err: err, duration: probeClock(time.Since(start))}
}

// the target's own login shell, named so the picker need not show the wrapper
var loginShell = []string{"sh", "-lc", `printf '\033]2;shell\a'; exec "${SHELL:-/bin/sh}"`}

func relTime(t time.Time) string {
	d := time.Since(t)
	switch {
	case d < time.Minute:
		return fmt.Sprintf("%ds ago", int(d.Seconds()))
	case d < time.Hour:
		return fmt.Sprintf("%dm ago", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%dh ago", int(d.Hours()))
	}
	return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}

func probeClock(d time.Duration) time.Duration { return min(d, 9900*time.Millisecond) }

func plural(n int) string { return lo.Ternary(n == 1, "", "s") }

func isTTY() bool { return term.IsTerminal(int(os.Stdout.Fd())) }
