package app

import (
	"encoding/json"
	"image/color"
	"io"
	"net/http"
	"strings"

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

// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse
// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields
// decode to the zero value ("" / nil) here.
type TaskSummary struct {
	ID                 string         `json:"id"`
	Key                string         `json:"key"`
	Status             string         `json:"status"`
	Title              string         `json:"title"`
	Goal               string         `json:"goal"`
	AcceptanceCriteria []string       `json:"acceptanceCriteria"`
	AffectedPaths      []string       `json:"affectedPaths"`
	Claimant           string         `json:"claimant"`
	Links              []TaskLinkWire `json:"links"`
	Notes              []TaskNoteWire `json:"notes"`
	CreatedAt          string         `json:"createdAt"`
	UpdatedAt          string         `json:"updatedAt"`
	// Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps);
	// BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values.
	Ready     bool     `json:"ready"`
	BlockedBy []string `json:"blockedBy"`
}

type TaskLinkWire struct {
	TargetID   string `json:"targetId"`
	Type       string `json:"type"`
	TargetKind string `json:"targetKind"`
}

type TaskNoteWire struct {
	Author string `json:"author"`
	Body   string `json:"body"`
	At     string `json:"at"`
}

// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks /
// err is meaningful; a non-empty err means the fetch failed and the board shows it.
type tasksLoadedMsg struct {
	tasks []TaskSummary
	err   string
}

// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and
// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws
// client); an empty base yields an error message rather than a panic.
func fetchTasks(base string) tea.Cmd {
	return func() tea.Msg {
		if base == "" {
			return tasksLoadedMsg{err: "no server address"}
		}
		client := &http.Client{Timeout: sessionsHTTPTimeout}
		resp, err := client.Get(base + "/tasks")
		if err != nil {
			return tasksLoadedMsg{err: "fetch failed: " + err.Error()}
		}
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			return tasksLoadedMsg{err: "server returned " + resp.Status}
		}
		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return tasksLoadedMsg{err: "read failed: " + err.Error()}
		}
		var out []TaskSummary
		if err := json.Unmarshal(body, &out); err != nil {
			return tasksLoadedMsg{err: "decode failed: " + err.Error()}
		}
		return tasksLoadedMsg{tasks: out}
	}
}

// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is
// global (not bound to a session), so it opens from anywhere.
func (m *Model) openTasks() tea.Cmd {
	m.overlay = OverlayTasks
	m.taskListIndex = 0
	m.taskListErr = ""
	m.taskDetail = false
	m.taskDetailScroll = 0
	m.taskFilter = ""
	m.taskFilterTyping = false
	m.taskListLoading = true
	return fetchTasks(m.client.HTTPBase())
}

// filteredTasks narrows the board by the `/` query — a case-insensitive substring over
// id/title/goal. An empty query returns the whole list.
func (m Model) filteredTasks() []TaskSummary {
	if strings.TrimSpace(m.taskFilter) == "" {
		return m.taskList
	}
	q := strings.ToLower(m.taskFilter)
	var out []TaskSummary
	for _, tk := range m.taskList {
		if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) {
			out = append(out, tk)
		}
	}
	return out
}

// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
// surfacing any fetch error in place of a crash.
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
	m.taskListLoading = false
	if msg.err != "" {
		m.taskListErr = msg.err
		return
	}
	m.taskListErr = ""
	m.taskList = msg.tasks
	if m.taskListIndex >= len(m.taskList) {
		m.taskListIndex = 0
	}
}

// selectedTask returns the highlighted task, or false when the list is empty / the
// cursor is out of range.
func (m Model) selectedTask() (TaskSummary, bool) {
	f := m.filteredTasks()
	if m.taskListIndex < 0 || m.taskListIndex >= len(f) {
		return TaskSummary{}, false
	}
	return f[m.taskListIndex], true
}

// handleTasksKey owns every key while the board is open. In list mode: navigate, r
// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and
// enter/esc returns to the list.
func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
	if m.taskDetail {
		switch {
		case k.Type == keyEsc || k.Type == keyEnter:
			m.taskDetail = false
		case k.Type == keyUp || runeIs(k, "k"):
			m.scrollTaskDetail(-1)
		case k.Type == keyDown || runeIs(k, "j"):
			m.scrollTaskDetail(1)
		case k.Type == keyPgUp:
			m.scrollTaskDetail(-m.taskDetailBodyH())
		case k.Type == keyPgDown:
			m.scrollTaskDetail(m.taskDetailBodyH())
		case runeIs(k, "g"):
			m.taskDetailScroll = 0
		case runeIs(k, "G"):
			m.taskDetailScroll = m.taskDetailMaxScroll()
		}
		return m, nil
	}
	// While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it).
	if m.taskFilterTyping {
		switch {
		case k.Type == keyEnter || k.Type == keyEsc:
			m.taskFilterTyping = false
		case k.Type == keyBackspace:
			if n := len(m.taskFilter); n > 0 {
				m.taskFilter = m.taskFilter[:n-1]
				m.taskListIndex = 0
			}
		case k.Type == keyRunes || k.Type == keySpace:
			m.taskFilter += string(k.Runes)
			m.taskListIndex = 0
		}
		return m, nil
	}
	switch {
	case k.Type == keyEsc || runeIs(k, "T"):
		m.overlay = OverlayNone
	case runeIs(k, "/"):
		m.taskFilterTyping = true
	case runeIs(k, "c"):
		m.taskFilter = ""
		m.taskListIndex = 0
	case k.Type == keyUp || runeIs(k, "k"):
		if m.taskListIndex > 0 {
			m.taskListIndex--
		}
	case k.Type == keyDown || runeIs(k, "j"):
		if m.taskListIndex < len(m.filteredTasks())-1 {
			m.taskListIndex++
		}
	case runeIs(k, "r"):
		return m, m.openTasks()
	case k.Type == keyEnter:
		if _, ok := m.selectedTask(); ok {
			m.taskDetail = true
			m.taskDetailScroll = 0
		}
	}
	return m, nil
}

// taskDetailBodyH is the number of detail lines visible in the detail pane.
func (m Model) taskDetailBodyH() int {
	h := m.height*70/100 - 6
	if h < 4 {
		h = 4
	}
	return h
}

// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom.
func (m Model) taskDetailMaxScroll() int {
	max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH()
	if max < 0 {
		max = 0
	}
	return max
}

// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max].
func (m *Model) scrollTaskDetail(delta int) {
	max := m.taskDetailMaxScroll()
	off := m.taskDetailScroll + delta
	if off > max {
		off = max
	}
	if off < 0 {
		off = 0
	}
	m.taskDetailScroll = off
}

func (m Model) tasksModal() string {
	if m.taskDetail {
		return m.taskDetailModal()
	}
	t := m.theme
	w := m.modalWidth()
	tasks := m.filteredTasks()

	var b strings.Builder
	b.WriteString(m.titleLine("tasks"))
	if len(tasks) > 0 {
		b.WriteString(mbg(t, "   ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
	}
	b.WriteString("\n\n")

	if m.taskListErr != "" {
		b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render("  ▲ "+truncate(m.taskListErr, w-8)) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}
	if m.taskListLoading && len(m.taskList) == 0 {
		b.WriteString(mbg(t, "  loading…", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}

	// Filter prompt: shown while editing the `/` query or whenever one is set.
	if m.taskFilterTyping || m.taskFilter != "" {
		b.WriteString(m.taskFilterLine() + "\n\n")
	}

	if len(tasks) == 0 {
		msg := "  no tasks"
		if m.taskFilter != "" {
			msg = "  no tasks match \"" + m.taskFilter + "\""
		}
		b.WriteString(mbg(t, msg, t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, m.taskListHints()))
		return t.Overlay.Width(w).Render(b.String())
	}

	// Windowed roster, keeping the selected row in view.
	bodyH := m.height*70/100 - 6
	if bodyH < 4 {
		bodyH = 4
	}
	off := 0
	if len(tasks) > bodyH {
		off = m.taskListIndex - bodyH/2
		if off < 0 {
			off = 0
		}
		if off > len(tasks)-bodyH {
			off = len(tasks) - bodyH
		}
	}
	end := off + bodyH
	if end > len(tasks) {
		end = len(tasks)
	}
	for i := off; i < end; i++ {
		b.WriteString(m.taskListRow(tasks, i) + "\n")
	}

	b.WriteString("\n" + modalHints(t, m.taskListHints()))
	return t.Overlay.Width(w).Render(b.String())
}

// taskListHints is the list-mode footer (shared by the populated and empty-filter views).
func (m Model) taskListHints() [][2]string {
	return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}}
}

// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing.
func (m Model) taskFilterLine() string {
	t := m.theme
	prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
	caret := mbg(t, "", t.P.BgPanel)
	if m.taskFilterTyping && m.caretVisible() {
		caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
	}
	if m.taskFilter == "" {
		return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint)
	}
	return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret
}

// taskListRow renders one board line: marker, id, status, title, and claimant.
func (m Model) taskListRow(tasks []TaskSummary, i int) string {
	t := m.theme
	tk := tasks[i]

	marker := mbg(t, "  ", t.P.BgPanel)
	idFg := t.P.Fg
	if i == m.taskListIndex {
		marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
		idFg = t.P.FgStrong
	}

	idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(tk.ID, 12))
	statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
		Render(padRaw(tk.Status, 12))

	claim := ""
	if tk.Claimant != "" {
		claim = " @" + shortSessionID(tk.Claimant)
	}
	readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph
	titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim))
	if titleW < 8 {
		titleW = 8
	}
	titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
	claimCell := mbg(t, claim, t.P.Faint)

	return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell
}

// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads
// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it
// is waiting on unfinished blockers, blank for anything already in flight or finished.
func taskReadyCell(t Theme, tk TaskSummary) string {
	switch {
	case tk.Ready:
		return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2))
	case len(tk.BlockedBy) > 0:
		return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2))
	default:
		return mbg(t, "  ", t.P.BgPanel)
	}
}

func (m Model) taskDetailModal() string {
	t := m.theme
	w := m.modalWidth()
	tk, _ := m.selectedTask()

	body := m.taskDetailBody(w - 4)
	bodyH := m.taskDetailBodyH()
	off := m.taskDetailScroll
	if mx := m.taskDetailMaxScroll(); off > mx {
		off = mx
	}
	if off < 0 {
		off = 0
	}

	var b strings.Builder
	b.WriteString(m.titleLine("task " + tk.ID))
	b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
		Render("  " + tk.Status))
	if len(body) > bodyH {
		b.WriteString(mbg(t, "   ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint))
	}
	b.WriteString("\n\n")

	end := off + bodyH
	if end > len(body) {
		end = len(body)
	}
	for i := off; i < end; i++ {
		b.WriteString(body[i] + "\n")
	}
	b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}}))
	return t.Overlay.Width(w).Render(b.String())
}

// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance
// criteria, affected paths, links, and notes. Built entirely from the list payload, so
// it needs no extra fetch.
func (m Model) taskDetailBody(width int) []string {
	t := m.theme
	tk, ok := m.selectedTask()
	if !ok {
		return []string{mbg(t, "  (no task selected)", t.P.Faint)}
	}
	if width < 8 {
		width = 8
	}
	var out []string
	section := func(label string) {
		out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label))
	}
	put := func(s string, fg color.Color) {
		for _, ln := range wrapLines([]string{s}, width) {
			out = append(out, mbg(t, ln, fg))
		}
	}

	put(tk.Title, t.P.FgStrong)
	if tk.Claimant != "" {
		out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
	}
	if tk.Ready {
		out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK))
	} else if len(tk.BlockedBy) > 0 {
		out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim))
	}
	out = append(out, "")

	if tk.Goal != "" {
		section("goal")
		put(tk.Goal, t.P.Fg)
		out = append(out, "")
	}
	if len(tk.AcceptanceCriteria) > 0 {
		section("acceptance criteria")
		for _, c := range tk.AcceptanceCriteria {
			put("- "+c, t.P.Fg)
		}
		out = append(out, "")
	}
	if len(tk.AffectedPaths) > 0 {
		section("affected paths")
		for _, p := range tk.AffectedPaths {
			out = append(out, mbg(t, clip("  "+p, width), t.P.Dim))
		}
		out = append(out, "")
	}
	if len(tk.Links) > 0 {
		section("links")
		for _, l := range tk.Links {
			out = append(out, mbg(t, clip("  "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg))
		}
		out = append(out, "")
	}
	if len(tk.Notes) > 0 {
		section("notes")
		for _, n := range tk.Notes {
			put("["+n.Author+"] "+n.Body, t.P.Fg)
		}
		out = append(out, "")
	}

	// Drop the trailing blank so the body never has a dangling empty line.
	for len(out) > 0 && out[len(out)-1] == "" {
		out = out[:len(out)-1]
	}
	return out
}

// taskStatusColor maps a task status to a palette color for the board / detail header.
func taskStatusColor(t Theme, status string) color.Color {
	switch status {
	case "DONE":
		return t.P.OK
	case "IN_PROGRESS":
		return t.P.Accent
	case "IN_REVIEW":
		return t.P.Accent2
	case "BLOCKED":
		return t.P.Bad
	case "CANCELLED":
		return t.P.Dim
	default: // TODO
		return t.P.Faint
	}
}
