package app

import (
	"encoding/json"
	"io"
	"net/http"
	"strings"
	"time"

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

// SessionSummary is one recent session from GET /sessions. Mirrors the server's
// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape.
type SessionSummary struct {
	SessionID      string `json:"sessionId"`
	Status         string `json:"status"`
	Name           string `json:"name"`
	Intent         string `json:"intent"`
	WorkflowID     string `json:"workflowId"`
	StageCount     int    `json:"stageCount"`
	CreatedAt      string `json:"createdAt"`
	LastActivityAt string `json:"lastActivityAt"`
}

// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of
// sessions / err is meaningful; err non-empty means the fetch failed and the
// overlay shows it instead of crashing.
type sessionsLoadedMsg struct {
	sessions []SessionSummary
	err      string
}

// sessionResumeMsg is the outcome of asking the server to resume a session
// (POST /sessions/{id}/resume). On success its events replay onto the live
// stream the app already drains; on failure the overlay shows the error.
type sessionResumeMsg struct {
	sessionID string
	err       string
}

// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI.
const sessionsHTTPTimeout = 5 * time.Second

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

// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a
// session (POST /sessions/{id}/resume). The server replays the rehydrated
// session onto the global stream this client already reads, so no WS re-dial is
// needed — the existing transport carries it.
func resumeSession(base, id string) tea.Cmd {
	return func() tea.Msg {
		if base == "" {
			return sessionResumeMsg{sessionID: id, err: "no server address"}
		}
		client := &http.Client{Timeout: sessionsHTTPTimeout}
		resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil)
		if err != nil {
			return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()}
		}
		defer resp.Body.Close()
		// 202 Accepted on success; 200 (already running) is fine too.
		if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
			return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status}
		}
		return sessionResumeMsg{sessionID: id}
	}
}

// openSessions opens the recent-session browser and kicks off the HTTP fetch.
// It is global (not bound to the selected session), so it opens from anywhere —
// including the idle list after a fresh reconnect with no live sessions yet.
func (m *Model) openSessions() tea.Cmd {
	m.overlay = OverlaySessions
	m.sessionListIndex = 0
	m.sessionListErr = ""
	m.sessionListLoading = true
	return fetchSessions(m.client.HTTPBase())
}

// handleSessionsKey owns every key while the session browser is open: navigate
// the roster, esc closes, enter resumes the selected session.
func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) {
	switch {
	case k.Type == keyEsc || runeIs(k, "R"):
		m.overlay = OverlayNone
	case k.Type == keyUp || runeIs(k, "k"):
		if m.sessionListIndex > 0 {
			m.sessionListIndex--
		}
	case k.Type == keyDown || runeIs(k, "j"):
		if m.sessionListIndex < len(m.sessionList)-1 {
			m.sessionListIndex++
		}
	case k.Type == keyEnter:
		if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
			return m.openSelectedSession()
		}
	}
	return m, nil
}

// openSelectedSession focuses the highlighted recent session locally and asks the
// server to resume it. Focusing it first means its replayed events land on the
// session the operator is now looking at; the resume cmd does the rest over the
// existing stream (no WS re-dial — see resumeSession).
func (m Model) openSelectedSession() (tea.Model, tea.Cmd) {
	sum := m.sessionList[m.sessionListIndex]
	s := m.ensureSession(sum.SessionID)
	if sum.WorkflowID != "" {
		s.WorkflowID = sum.WorkflowID
		if s.Name == "" {
			s.Name = sum.WorkflowID
		}
	}
	if sum.Status != "" {
		s.Status = sum.Status
	}
	m.selectedID = sum.SessionID
	m.sessionEntered = true
	m.overlay = OverlayNone
	return m, resumeSession(m.client.HTTPBase(), sum.SessionID)
}

func (m Model) sessionsModal() string {
	t := m.theme
	w := m.modalWidth()

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

	if m.sessionListErr != "" {
		b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render("  ▲ "+truncate(m.sessionListErr, w-8)) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}
	if m.sessionListLoading && len(m.sessionList) == 0 {
		b.WriteString(mbg(t, "  loading…", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
		return t.Overlay.Width(w).Render(b.String())
	}
	if len(m.sessionList) == 0 {
		b.WriteString(mbg(t, "  no recent sessions", t.P.Faint) + "\n")
		b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
		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(m.sessionList) > bodyH {
		off = m.sessionListIndex - bodyH/2
		if off < 0 {
			off = 0
		}
		if off > len(m.sessionList)-bodyH {
			off = len(m.sessionList) - bodyH
		}
	}
	end := off + bodyH
	if end > len(m.sessionList) {
		end = len(m.sessionList)
	}
	for i := off; i < end; i++ {
		b.WriteString(m.sessionListRow(i) + "\n")
	}

	b.WriteString("\n" + modalHints(t, [][2]string{
		{"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"},
	}))
	return t.Overlay.Width(w).Render(b.String())
}

// sessionListRow renders one roster line: marker, short id, status, current
// stage (if any), and relative age of the last activity.
func (m Model) sessionListRow(i int) string {
	t := m.theme
	s := m.sessionList[i]

	marker := mbg(t, "  ", t.P.BgPanel)
	idFg := t.P.Fg
	if i == m.sessionListIndex {
		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(shortSessionID(s.SessionID), 8))
	statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true).
		Render(padRaw(statusLabel(s.Status), 9))

	// Prefer the human session name (SessionNamedEvent, intent-derived) over the raw
	// workflow id — it's what the operator recognizes a session by.
	stage := s.Name
	if stage == "" {
		stage = s.WorkflowID
	}
	if s.StageCount > 0 {
		stage += " ·" + itoa(s.StageCount) + "stg"
	}
	stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2)

	dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim)
	ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint)

	return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell
}

// absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local
// date via shortDateTime, or "—" when absent/unparseable.
func absDateISO(iso string) string {
	if iso == "" {
		return "—"
	}
	ts, err := time.Parse(time.RFC3339Nano, iso)
	if err != nil {
		if ts, err = time.Parse(time.RFC3339, iso); err != nil {
			return "—"
		}
	}
	return shortDateTime(ts.UnixMilli())
}

// shortSessionID trims a long session id to a recognizable prefix for the list.
func shortSessionID(id string) string {
	if len(id) <= 8 {
		return id
	}
	return id[:8]
}

// relativeAge renders an ISO-8601 instant string (Instant.toString() on the
// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable.
func relativeAge(iso string) string {
	if iso == "" {
		return "—"
	}
	ts, err := time.Parse(time.RFC3339Nano, iso)
	if err != nil {
		ts, err = time.Parse(time.RFC3339, iso)
		if err != nil {
			return "—"
		}
	}
	return agoLabel(nowMillis() - ts.UnixMilli())
}
