package web

import (
	"context"
	"errors"
	"net/http"
	"strings"

	"git.d-ma.be/mathias/tapir/internal/adapters/chat"
	"git.d-ma.be/mathias/tapir/internal/adapters/store"
)

// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
// tests substitute a fake. It carries NO caption-fetch dependency — the chat
// handlers reach it only after reading an already-stored transcript, so an
// enabled chat cannot trigger a fetch, touch the rate gate, or reach YouTube.
type Chatter interface {
	// Models returns the offerable models, local-first (cloud absent when disabled).
	Models() []string
	// DefaultModel resolves the model a fresh chat opens with given the summary's
	// model (the ADR-027 default), falling back to the first offered model.
	DefaultModel(summaryModel string) string
	// Answer runs one chat turn against the supplied stored transcript text.
	Answer(ctx context.Context, req chat.Request) (chat.Reply, error)
}

// maxHistoryTurns bounds the ephemeral conversation carried per request, so a long
// back-and-forth cannot grow the prompt without limit (the transcript already
// dominates the budget). Older turns drop off the front.
const maxHistoryTurns = 8

// chatView is everything the chat templates render: the video identity for links
// and titles, the model switcher state, the running (ephemeral) conversation, and
// the honest flags — Available is false when no usable transcript is stored
// (ADR-027: honest "not available", never a fetch), Truncated when the transcript
// was bounded to fit the model, Error for a transient model failure.
type chatView struct {
	VideoID   string
	Title     string
	Available bool
	Models    []string
	Selected  string
	History   []chat.Turn
	Truncated bool
	Error     string
}

// handleChat renders the chat page for a summarized video (GET). Entry is scoped
// through GetSummaryByVideo, which is RLS/user-scoped: a video that is not the
// requesting user's own resolves to ErrNotFound → 404, so chat is reachable only
// from the user's own summary view (ADR-027 isolation). The transcript is read
// from the SHARED store (ADR-021) — a pure DB read, never a caption fetch; an
// absent/text-less transcript renders the honest "not available" state, no fetch.
func (a *App) handleChat(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	row, ok := a.loadOwnedSummary(w, r, userID)
	if !ok {
		return
	}
	_, hasText, ok := a.readTranscript(w, r, *row)
	if !ok {
		return
	}
	view := chatView{
		VideoID:   row.VideoID,
		Title:     displayTitle(*row),
		Available: hasText,
		Models:    a.Chat.Models(),
		Selected:  a.Chat.DefaultModel(row.AIModel),
	}
	// HTMX (the in-place reveal from the summary) gets just the open chat section,
	// swapped over the closed dock so the summary above it stays put. A no-JS
	// navigation gets the full page: the whole summary plus the open chat.
	if isHTMX(r) {
		a.render(w, r, chatSection(view))
		return
	}
	a.render(w, r, ChatPage(*row, view))
}

// handleChatMessage answers one question against the stored transcript (POST).
// It reads the transcript from the store (no fetch), runs the chosen model over
// it plus the prior turns, appends the answer, and returns the refreshed chat
// panel (HTMX) or the whole page (no-JS). A model failure is surfaced inline,
// not as a 500 — the conversation and the question are preserved for a retry.
func (a *App) handleChatMessage(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	row, ok := a.loadOwnedSummary(w, r, userID)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	transcript, hasText, ok := a.readTranscript(w, r, *row)
	if !ok {
		return
	}

	model := a.resolveModel(r.FormValue("model"), row.AIModel)
	history := parseHistory(r.Form["hq"], r.Form["ha"])
	question := strings.TrimSpace(r.FormValue("question"))

	view := chatView{
		VideoID:   row.VideoID,
		Title:     displayTitle(*row),
		Available: hasText,
		Models:    a.Chat.Models(),
		Selected:  model,
		History:   history,
	}

	switch {
	case !hasText:
		// Honest "not available" — no fetch, no model call (ADR-027).
	case question == "":
		// A model switch with no text just re-renders — no wasted round-trip.
	default:
		reply, err := a.Chat.Answer(r.Context(), chat.Request{
			Model:      model,
			Transcript: transcript,
			History:    history,
			Question:   question,
		})
		if err != nil {
			a.logger().Error("chat answer", "video", row.VideoID, "model", model, "err", err)
			view.Error = "That model couldn't answer just now. Try again, or switch models."
		} else {
			view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
			view.Truncated = reply.Truncated
		}
	}
	a.renderChatTurn(w, r, *row, view)
}

// loadOwnedSummary fetches the summary for the path's video scoped to userID, or
// writes the right response (404 on not-found/not-owned, 500 on error) and reports
// false. It is the single isolation gate for both chat handlers.
func (a *App) loadOwnedSummary(w http.ResponseWriter, r *http.Request, userID string) (*store.SummaryRow, bool) {
	videoID := r.PathValue("videoId")
	row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return nil, false
	}
	if err != nil {
		a.serverError(w, r, "chat get summary", err)
		return nil, false
	}
	return row, true
}

// readTranscript reads the shared stored transcript for a row (ADR-021) and
// reports whether it carries usable text. It is a pure DB read — NO caption fetch,
// the property the whole feature's safety rests on. ok is false only on a store
// error (after a 500 is written); a missing/text-less transcript is (",", false,
// true) — the honest "not available" case, handled by the caller, not an error.
func (a *App) readTranscript(w http.ResponseWriter, r *http.Request, row store.SummaryRow) (content string, hasText, ok bool) {
	t, found, err := a.Store.GetTranscript(r.Context(), row.Channel, row.ProviderVideoID)
	if err != nil {
		a.serverError(w, r, "chat get transcript", err)
		return "", false, false
	}
	if !found || !t.HasText() {
		return "", false, true
	}
	return t.Content, true, true
}

// renderChatTurn returns the chat panel fragment for an HTMX answer (swapped in
// place within the open dock), or the full chat page otherwise — the no-JS POST
// re-renders the whole summary + open chat with the new turn.
func (a *App) renderChatTurn(w http.ResponseWriter, r *http.Request, row store.SummaryRow, v chatView) {
	if isHTMX(r) {
		a.render(w, r, chatPanel(v))
		return
	}
	a.render(w, r, ChatPage(row, v))
}

// resolveModel keeps the posted model only when it is an offered option; anything
// else (a forged value, or a model dropped because cloud is disabled) falls back
// to the default. The chat.Service enforces the same guard before the gateway;
// this keeps the rendered switcher honest too.
func (a *App) resolveModel(posted, summaryModel string) string {
	for _, m := range a.Chat.Models() {
		if m == posted {
			return posted
		}
	}
	return a.Chat.DefaultModel(summaryModel)
}

// parseHistory zips the parallel hidden hq/ha fields back into ordered turns,
// keeping only the most recent maxHistoryTurns. net/url preserves the submission
// order of repeated fields, so the pairing is stable.
func parseHistory(qs, as []string) []chat.Turn {
	n := len(qs)
	if len(as) < n {
		n = len(as)
	}
	turns := make([]chat.Turn, 0, n)
	for i := 0; i < n; i++ {
		turns = append(turns, chat.Turn{Question: qs[i], Answer: as[i]})
	}
	return capHistory(turns)
}

// appendTurn adds a completed exchange and re-bounds the conversation.
func appendTurn(history []chat.Turn, t chat.Turn) []chat.Turn {
	return capHistory(append(history, t))
}

// capHistory keeps the last maxHistoryTurns turns (drops the oldest).
func capHistory(turns []chat.Turn) []chat.Turn {
	if len(turns) <= maxHistoryTurns {
		return turns
	}
	return turns[len(turns)-maxHistoryTurns:]
}
