package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"sort"
	"strings"

	"github.com/go-chi/chi/v5"

	"rk/internal/tmux"
	"rk/internal/validate"
)

// BoardEntryResponse joins a BoardEntry with live window data for the
// GET /api/boards/{name} endpoint.
type BoardEntryResponse struct {
	Server      string          `json:"server"`
	WindowID    string          `json:"windowId"`
	Session     string          `json:"session"`
	WindowIndex int             `json:"windowIndex"`
	WindowName  string          `json:"windowName"`
	OrderKey    string          `json:"orderKey"`
	Panes       []tmux.PaneInfo `json:"panes,omitempty"`
}

func (s *Server) handleBoardsList(w http.ResponseWriter, r *http.Request) {
	boards, err := s.tmux.ListBoards(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if boards == nil {
		boards = []tmux.BoardSummary{}
	}
	writeJSON(w, http.StatusOK, boards)
}

func (s *Server) handleBoardGet(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if !tmux.ValidBoardName(name) {
		writeError(w, http.StatusBadRequest, "invalid board name")
		return
	}
	entries, err := s.tmux.GetBoard(r.Context(), name)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	out := make([]BoardEntryResponse, 0, len(entries))

	// Join each board entry with live window data. A pinned window has been MOVED
	// into its own single-window pin-session `_rk-pin-<id>`, so the window lives
	// inside a session that the user-facing `ListSessions`/`parseSessions` path
	// deliberately HIDES (the `_rk-pin-` skip). Enumerating via `ListSessions`
	// would therefore never find the pinned window and the board would render
	// empty (the CI/e2e failure this replaced). Instead, look the window up in its
	// OWN pin-session directly: the entry's WindowID maps deterministically to its
	// pin-session name, and `ListWindows -t <pinSession>` is a by-name target query
	// that is NOT subject to the session-list filter. O(entries) targeted lookups.
	for _, e := range entries {
		pinSession, ok := tmux.PinSessionName(e.WindowID)
		if !ok {
			// Malformed window id (should not occur — entries come from pin
			// sessions) — skip defensively.
			continue
		}
		windows, wErr := s.tmux.ListWindows(r.Context(), pinSession, e.Server)
		if wErr != nil || len(windows) == 0 {
			// Pin-session vanished between GetBoard and the join (window/pin
			// killed) — skip; the board simply shows one fewer pane.
			continue
		}
		// A pin-session holds exactly one window — its sole window IS the pinned
		// window. Match by WindowID defensively in case of an unexpected extra.
		win := windows[0]
		for _, w := range windows {
			if w.WindowID == e.WindowID {
				win = w
				break
			}
		}
		out = append(out, BoardEntryResponse{
			Server:      e.Server,
			WindowID:    e.WindowID,
			Session:     pinSession,
			WindowIndex: win.Index,
			WindowName:  win.Name,
			OrderKey:    e.OrderKey,
			Panes:       win.Panes,
		})
	}
	// Stable sort by orderKey to preserve the GetBoard ordering after the join.
	sort.SliceStable(out, func(i, j int) bool { return out[i].OrderKey < out[j].OrderKey })
	writeJSON(w, http.StatusOK, out)
}

type pinRequestBody struct {
	Server   string `json:"server"`
	WindowID string `json:"windowId"`
}

// reorderRequestBody mirrors the documented API contract — `before` and
// `after` are nullable: `null` (or omitted) means prepend/append, a non-null
// string is the neighbour windowId. Modeled as `*string` so JSON `null`
// decodes cleanly (rather than failing the decoder, which a plain `string`
// would).
type reorderRequestBody struct {
	Server   string  `json:"server"`
	WindowID string  `json:"windowId"`
	Before   *string `json:"before"`
	After    *string `json:"after"`
}

// validatePinRequest decodes & validates the body shared by pin/unpin/reorder
// (the reorder body has additional fields handled separately).
func validatePinRequest(r *http.Request, body interface{}) (string, int) {
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(body); err != nil {
		return "Invalid JSON body", http.StatusBadRequest
	}
	return "", 0
}

func (s *Server) handleBoardPin(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if !tmux.ValidBoardName(name) {
		writeError(w, http.StatusBadRequest, "invalid board name")
		return
	}
	var body pinRequestBody
	if msg, code := validatePinRequest(r, &body); msg != "" {
		writeError(w, code, msg)
		return
	}
	if errMsg := validate.ValidateServerName(body.Server); errMsg != "" {
		writeError(w, http.StatusBadRequest, errMsg)
		return
	}
	if !tmux.ValidWindowID(body.WindowID) {
		writeError(w, http.StatusBadRequest, "invalid window id")
		return
	}

	// Verify the window exists on the named server before mutating.
	if !s.windowExistsOnServer(r, body.Server, body.WindowID) {
		writeError(w, http.StatusNotFound, "window not found on server")
		return
	}

	if err := s.tmux.PinBoard(r.Context(), body.Server, body.WindowID, name); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}

	// Best-effort: read the current order key back so the broadcast carries it.
	orderKey := s.lookupOrderKey(r, body.Server, body.WindowID, name)

	s.initSSEHub()
	s.sseHub.broadcastBoardChanged(body.Server, boardChangedPayload{
		Board:    name,
		Change:   "pin",
		Server:   body.Server,
		WindowID: body.WindowID,
		OrderKey: orderKey,
	})

	writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}

func (s *Server) handleBoardUnpin(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if !tmux.ValidBoardName(name) {
		writeError(w, http.StatusBadRequest, "invalid board name")
		return
	}
	var body pinRequestBody
	if msg, code := validatePinRequest(r, &body); msg != "" {
		writeError(w, code, msg)
		return
	}
	if errMsg := validate.ValidateServerName(body.Server); errMsg != "" {
		writeError(w, http.StatusBadRequest, errMsg)
		return
	}
	if !tmux.ValidWindowID(body.WindowID) {
		writeError(w, http.StatusBadRequest, "invalid window id")
		return
	}
	if err := s.tmux.UnpinBoard(r.Context(), body.Server, body.WindowID, name); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	s.initSSEHub()
	s.sseHub.broadcastBoardChanged(body.Server, boardChangedPayload{
		Board:    name,
		Change:   "unpin",
		Server:   body.Server,
		WindowID: body.WindowID,
	})
	writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

func (s *Server) handleBoardReorder(w http.ResponseWriter, r *http.Request) {
	name := chi.URLParam(r, "name")
	if !tmux.ValidBoardName(name) {
		writeError(w, http.StatusBadRequest, "invalid board name")
		return
	}
	var body reorderRequestBody
	if msg, code := validatePinRequest(r, &body); msg != "" {
		writeError(w, code, msg)
		return
	}
	if errMsg := validate.ValidateServerName(body.Server); errMsg != "" {
		writeError(w, http.StatusBadRequest, errMsg)
		return
	}
	if !tmux.ValidWindowID(body.WindowID) {
		writeError(w, http.StatusBadRequest, "invalid window id")
		return
	}
	// `before`/`after` are nullable per the API contract. Treat both `null`
	// (pointer is nil) and `""` as prepend/append sentinels for backward
	// compatibility with clients that emit empty strings. Non-empty must be a
	// valid window id.
	before := ""
	if body.Before != nil {
		before = *body.Before
	}
	after := ""
	if body.After != nil {
		after = *body.After
	}
	if before != "" && !tmux.ValidWindowID(before) {
		writeError(w, http.StatusBadRequest, "invalid before window id")
		return
	}
	if after != "" && !tmux.ValidWindowID(after) {
		writeError(w, http.StatusBadRequest, "invalid after window id")
		return
	}
	newKey, err := s.tmux.ReorderBoard(r.Context(), body.Server, body.WindowID, name, before, after)
	if err != nil {
		// Distinguish "neighbour not found" from internal errors.
		if errors.Is(err, errNeighbourNotFound) || strings.Contains(err.Error(), "neighbour window not found") || strings.Contains(err.Error(), "entry not found") {
			writeError(w, http.StatusBadRequest, err.Error())
			return
		}
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	s.initSSEHub()
	s.sseHub.broadcastBoardChanged(body.Server, boardChangedPayload{
		Board:    name,
		Change:   "reorder",
		Server:   body.Server,
		WindowID: body.WindowID,
		OrderKey: newKey,
	})
	writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "newOrderKey": newKey})
}

// windowExistsOnServer returns true if the supplied windowID matches a live
// window on the server — whether the window is in a normal (home) session OR
// already moved into its own pin-session.
//
// The pin-session must be checked explicitly: `ListSessions`/`parseSessions`
// HIDES `_rk-pin-*` sessions, so a window that is ALREADY pinned would be
// invisible to the home-session scan alone. Without the pin-session check, a
// re-pin of an already-pinned window (e.g. moving it to a different board) would
// be rejected 404 before reaching tmux.Pin's wrong-board re-stamp path.
func (s *Server) windowExistsOnServer(r *http.Request, server, windowID string) bool {
	// Fast path: the window's own pin-session (by-name target, not subject to the
	// session-list filter). If present, the window is already pinned and live.
	if pinSession, ok := tmux.PinSessionName(windowID); ok {
		if windows, err := s.tmux.ListWindows(r.Context(), pinSession, server); err == nil {
			for _, w := range windows {
				if w.WindowID == windowID {
					return true
				}
			}
		}
	}
	// Otherwise scan the visible (home) sessions.
	sessions, err := s.tmux.ListSessions(r.Context(), server)
	if err != nil {
		return false
	}
	for _, sess := range sessions {
		windows, err := s.tmux.ListWindows(r.Context(), sess.Name, server)
		if err != nil {
			continue
		}
		for _, w := range windows {
			if w.WindowID == windowID {
				return true
			}
		}
	}
	return false
}

// lookupOrderKey returns the order key for a (server, windowID, board) tuple.
// Returns empty string if not found (best-effort — the broadcast tolerates a
// missing key).
func (s *Server) lookupOrderKey(r *http.Request, server, windowID, board string) string {
	entries, err := s.tmux.ListBoardEntries(r.Context(), server)
	if err != nil {
		return ""
	}
	for _, e := range entries {
		if e.WindowID == windowID && e.Board == board {
			return e.OrderKey
		}
	}
	return ""
}
