package web

import (
	"context"
	"errors"
	"html/template"
	"io"
	"log/slog"
	"net/http"
	"time"

	"github.com/a-h/templ"

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

// Store is the read/write surface the web handlers depend on — a narrow port over
// the Postgres store (Clean Architecture: handlers depend on this interface, not
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
// fake without a database.
type Store interface {
	ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
	// DistinctChannels lists the user's source channels — the options for the
	// feed's channel multi-select filter.
	DistinctChannels(ctx context.Context, userID string) ([]string, error)
	GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
	GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
	// GetTranscript reads the shared, stored transcript keyed by (provider,
	// providerVideoID) — ADR-021. It is a pure DB read: it never fetches captions,
	// so the chat path (ADR-027) reaches it without any caption-fetch surface.
	GetTranscript(ctx context.Context, provider, providerVideoID string) (domain.Transcript, bool, error)
	ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
	SetAction(ctx context.Context, userID, videoID, action string) error
	ClearAction(ctx context.Context, userID, videoID, action string) error

	// Summarization mode: the per-user auto/manual toggle and the per-video
	// manual queue (the "Summarize" button). The runner consumes the queue.
	GetAutoSummarize(ctx context.Context, userID string) (bool, error)
	SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
	RequestSummarize(ctx context.Context, userID, videoID string) error

	// UpsertVideo persists a pasted video (idempotent on user+provider+video id,
	// so it also dedups) and returns its durable store id.
	UpsertVideo(ctx context.Context, v domain.Video) (string, error)

	// Account management (the /account page, disconnect, delete-account).
	ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
	DeleteConnection(ctx context.Context, userID, provider string) error
	DeleteUser(ctx context.Context, userID string) error
	DisplayName(ctx context.Context, userID string) (string, error)
	// ListChannelErrors returns channels that returned HTTP 404 (deleted/private) on
	// the most recent discovery pass, shown on the account page as a warning.
	ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)

	// SetTranscriptStatus clears or updates a video's transcript backoff state.
	// Used by handleRetryNow to clear rate_limited_at before immediate processing.
	SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error

	// StampLogin records (throttled, one row per user per day) that the resolved
	// user was active on this request — the read-side Stage-0 usage signal the
	// registration gate stamps for every authenticated request.
	StampLogin(ctx context.Context, userID string) error
}

// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
// satisfies it; account tests use a fake. The account handlers depend only on
// this narrow capability (not the read-side ports.SecretStore), mirroring how the
// connect flow depends on auth.TokenWriter for the write side.
type SecretRemover interface {
	Delete(ref string) error
}

// App is the Stage-1 web surface: handlers over the store, gated by an Auth
// implementation (authentication) and a registration gate (which resolves the
// authenticated subject to its tapir user_id and stashes it per request). Every
// data handler scopes by that resolved id — CurrentUserID(r) — not by a single
// configured user (ADR-012, multi-user with enforced isolation).
type App struct {
	Store    Store
	Identity Identity
	Auth     Auth
	Log      *slog.Logger
	// Connect runs the web-initiated YouTube OAuth connect flow. Optional: when
	// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
	// routes are not mounted.
	Connect *ConnectHandler
	// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
	// account routes require it; cmd/tapir wires the file-backed store.
	Secrets SecretRemover
	// Processor, when non-nil, summarizes a queued video immediately in a
	// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
	// the button flips the DB flag and the next `tapir run` does the work.
	Processor Processor
	// Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for
	// the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted.
	Fetcher VideoFetcher
	// Chat, when non-nil, answers per-video questions against a video's STORED
	// transcript (ADR-027). Nil = the /v/{id}/chat routes are not mounted and the
	// summary view shows no "dig deeper" affordance. It holds no caption-fetch
	// dependency, so an enabled chat cannot reach YouTube or the rate gate.
	Chat Chatter
	// Processing tracks in-flight immediate summarizations so the status endpoint
	// shows the animation until the summary lands. The zero value is ready to use.
	Processing ProcessingSet
	// RecencyWindow mirrors the auto-summarize recency bound: un-summarized videos
	// published before now-RecencyWindow collapse into the "older videos"
	// disclosure on the list, so the readable summaries are not buried (B3). Zero
	// disables the collapse (everything stays inline).
	RecencyWindow time.Duration
	// Now is an injectable clock for the recency cutoff (tests fix it). Nil =
	// time.Now.
	Now func() time.Time
}

// now returns the App's clock (time.Now unless overridden for tests).
func (a *App) now() time.Time {
	if a.Now != nil {
		return a.Now()
	}
	return time.Now()
}

// recencyCutoff is the timestamp before which an un-summarized video counts as
// "older" and collapses into the disclosure. A zero RecencyWindow yields the zero
// time, which bucketRows treats as "collapse disabled".
func (a *App) recencyCutoff() time.Time {
	if a.RecencyWindow <= 0 {
		return time.Time{}
	}
	return a.now().Add(-a.RecencyWindow)
}

func (a *App) logger() *slog.Logger {
	if a.Log != nil {
		return a.Log
	}
	return slog.Default()
}

// Router wires the routes: /healthz (no auth) and /auth/* (the Auth impl's own
// endpoints) sit outside the guard; everything else is wrapped by
// Auth.Middleware. Go 1.22+ method+path patterns route directly.
func (a *App) Router() http.Handler {
	root := http.NewServeMux()
	root.HandleFunc("GET /healthz", a.handleHealthz)
	root.HandleFunc("GET /welcome", a.handleWelcome)
	root.Handle("GET /static/", staticHandler())
	root.Handle("/auth/", a.Auth.Routes())

	app := http.NewServeMux()
	app.HandleFunc("GET /{$}", a.handleList)
	app.HandleFunc("GET /v/{videoId}", a.handleDetail)
	app.HandleFunc("GET /v/{videoId}/expand", a.handleExpand)
	app.HandleFunc("GET /v/{videoId}/card", a.handleCard)
	app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
	app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
	app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
	if a.Fetcher != nil {
		app.HandleFunc("POST /paste", a.handlePaste)
	}
	app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
	// Per-video deeper-dive chat over the STORED transcript (ADR-027). Mounted only
	// when a Chat backend is wired; it never fetches captions.
	if a.Chat != nil {
		app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
		app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage)
	}
	app.HandleFunc("GET /register", a.handleRegisterForm)
	app.HandleFunc("POST /register", a.handleRegister)

	// Account management: view connections, disconnect a provider, delete the
	// account. Gated like every app route, so CurrentUserID is set.
	app.HandleFunc("GET /account", a.handleAccount)
	app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
	app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
	app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)

	// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
	// CurrentUserID is set and the connection binds to the authenticated user.
	if a.Connect != nil {
		app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect)
		app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback)
	}

	// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
	// registrationGate requires a tapir user (else → /register) and stashes the
	// resolved user_id. /register lives inside the auth guard but is exempt from
	// the registration gate (you must be able to reach it before you have a user).
	root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
	return root
}

// handleWelcome renders the public landing page (/welcome). It is mounted outside
// Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie
// without redirecting and the page renders the logged-out or logged-in variant
// accordingly.
func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) {
	user, ok := a.Auth.CurrentUser(r)
	a.render(w, r, WelcomePage(user, ok))
}

// handleHealthz is the unauthenticated liveness/readiness probe.
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	_, _ = w.Write([]byte("ok"))
}

// handleList renders the summary list, applying the channel/date filters from the
// query string. An HTMX request gets only the table fragment so the filter form
// can swap #summary-list in place; a plain request gets the full page.
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	q := r.URL.Query()
	f := Filter{
		Channels:       nonEmptyStrings(q["channel"]),
		From:           q.Get("from"),
		To:             q.Get("to"),
		OnlySummarized: q.Get("summarized") == "1",
	}

	allRows, err := a.Store.ListVideos(r.Context(), userID, 0)
	if err != nil {
		a.serverError(w, r, "list videos", err)
		return
	}
	stats := pipelineStats(allRows)
	rows := f.apply(allRows)
	buckets := bucketRows(rows, a.recencyCutoff())

	// Channel options for the multi-select filter (the user's source channels).
	channels, err := a.Store.DistinctChannels(r.Context(), userID)
	if err != nil {
		a.serverError(w, r, "distinct channels", err)
		return
	}

	// hasConnected drives both the paste box (shown to ANY connected user, #2) and
	// the empty-state copy (a fresh account with a connection but no discovery pass
	// yet reads "connected, summaries land gradually" rather than "nothing here").
	// Computed every render — not only when empty — so a user with videos still
	// gets the paste box.
	conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
	if err != nil {
		a.serverError(w, r, "connections for user", err)
		return
	}
	hasConnected := len(conns) > 0

	// Summarization mode drives the backlog copy: an auto user is told summaries
	// land gradually; a manual user is told to click Summarize (the first pilot
	// user sat in manual mode reading "land automatically" and waited forever).
	autoSummarize, err := a.Store.GetAutoSummarize(r.Context(), userID)
	if err != nil {
		a.serverError(w, r, "summarize mode", err)
		return
	}

	if isHTMX(r) {
		a.render(w, r, summaryList(buckets, hasConnected, autoSummarize))
		return
	}
	a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize))
}

// handleDetail renders one summary in full (highlights, takeaways, action group).
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")
	row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		a.serverError(w, r, "get summary", err)
		return
	}
	a.render(w, r, DetailPage(*row, a.Chat != nil))
}

// handleExpand returns the inline-expanded card fragment — the full summary +
// chat dock swapped into the list card in place (ADR-031). Only summarized videos
// have a summary to expand; a non-summarized id is a 404 (the compact card never
// offers expand for it).
func (a *App) handleExpand(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")
	row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		a.serverError(w, r, "get summary", err)
		return
	}
	a.render(w, r, expandedCard(*row, a.Chat != nil))
}

// handleCard returns the compact card fragment — the collapse target that returns
// an expanded card to its compact form in the list (ADR-031).
func (a *App) handleCard(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")
	row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		a.serverError(w, r, "get video", err)
		return
	}
	a.render(w, r, VideoCard(*row))
}

// handleAction toggles one action: re-clicking an active verb clears it, else it
// is set (the store enforces watched↔skipped exclusion atomically). It returns
// the refreshed button-group fragment for HTMX; without JS it redirects back to
// the detail page (POST→redirect→GET).
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")
	action := r.FormValue("action")
	if !isActionVerb(action) {
		http.Error(w, "unknown action", http.StatusBadRequest)
		return
	}

	current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
	if err != nil {
		a.serverError(w, r, "read actions", err)
		return
	}

	if actionSet(current[videoID])[action] {
		err = a.Store.ClearAction(r.Context(), userID, videoID, action)
	} else {
		err = a.Store.SetAction(r.Context(), userID, videoID, action)
	}
	if err != nil {
		a.serverError(w, r, "toggle action", err)
		return
	}

	updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
	if err != nil {
		a.serverError(w, r, "read actions", err)
		return
	}

	if isHTMX(r) {
		a.render(w, r, ActionButtons(videoID, actionSet(updated[videoID])))
		return
	}
	http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
}

// handleRequestSummarize handles the "Summarize" button. It always flips the DB
// flag (summarize_requested) so the work is durable. With a Processor wired it
// then summarizes immediately in the background and answers with the animated
// processing card that polls /status until done; without one (queue-only) it
// answers with the "Queued" card — the next `tapir run` does the work. Without
// JS it redirects back to the list (POST→redirect→GET).
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")

	err := a.Store.RequestSummarize(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		a.serverError(w, r, "request summarize", err)
		return
	}

	if !isHTMX(r) {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
	if err != nil {
		a.serverError(w, r, "get video", err)
		return
	}

	if a.Processor != nil {
		a.startProcessing(userID, videoID)
		a.render(w, r, processingCard(*row))
		return
	}
	a.render(w, r, VideoCard(*row))
}

// handlePaste handles "paste a YouTube URL" (Feature 2). It parses the video id,
// fetches metadata (Data API — ungated), upserts a subscription-less video row
// scoped to the user (idempotent, so it also dedups), and — if the video isn't
// already summarized — requests a summary and kicks off immediate processing
// through the SAME rate gate as the Summarize button. An explicit paste is a
// manual request, so it summarizes regardless of the recency window. A video that
// turns out to have no captions resolves to the honest "no transcript" terminal
// state via the engine (ADR-010), not an error here.
func (a *App) handlePaste(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID, err := parseYouTubeVideoID(r.FormValue("url"))
	if err != nil {
		a.pasteFailure(w, http.StatusBadRequest, "That doesn't look like a YouTube video link.")
		return
	}

	v, err := a.Fetcher.FetchVideo(r.Context(), userID, videoID)
	if errors.Is(err, domain.ErrVideoNotFound) {
		a.pasteFailure(w, http.StatusNotFound, "That video couldn't be found — it may be private or removed.")
		return
	}
	if err != nil {
		a.serverError(w, r, "paste fetch", err)
		return
	}

	id, err := a.Store.UpsertVideo(r.Context(), v)
	if err != nil {
		a.serverError(w, r, "paste upsert", err)
		return
	}
	row, err := a.Store.GetVideoRow(r.Context(), userID, id)
	if err != nil {
		a.serverError(w, r, "paste get video", err)
		return
	}

	// Dedup: already in the feed with a summary — surface the existing entry,
	// don't re-summarize.
	if row.Summarized {
		a.render(w, r, VideoCard(*row))
		return
	}

	// New or unsummarized: queue + (if a Processor is wired) summarize now, through
	// the shared gate. RequestSummarize makes it durable even if the process dies.
	if err := a.Store.RequestSummarize(r.Context(), userID, id); err != nil {
		a.serverError(w, r, "paste request summarize", err)
		return
	}
	if a.Processor != nil {
		a.startProcessing(userID, id)
		a.render(w, r, processingCard(*row))
		return
	}
	a.render(w, r, VideoCard(*row))
}

// pasteFailure renders a minimal inline error fragment for the paste form (HTMX
// swaps it in). No templ dependency so it renders even on a bad-input fast path.
func (a *App) pasteFailure(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	_, _ = io.WriteString(w, `<p class="paste-error" role="alert">`+template.HTMLEscapeString(msg)+`</p>`)
}

// handleRetryNow handles the "Try now" button on rate-limited video cards. It
// clears the rate_limited_at backoff so the scheduler won't skip the video, then
// triggers an immediate ProcessVideo — same background path as handleRequestSummarize.
// The rate gate (globalFetchGate) still applies, so this is safe under concurrent use.
func (a *App) handleRetryNow(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")

	// Clear the backoff so the scheduler won't skip this video on the next pass.
	if err := a.Store.SetTranscriptStatus(r.Context(), userID, videoID, "none"); err != nil {
		a.serverError(w, r, "clear rate limit", err)
		return
	}

	if !isHTMX(r) {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
	if err != nil {
		a.serverError(w, r, "get video", err)
		return
	}
	if a.Processor != nil {
		a.startProcessing(userID, videoID)
		a.render(w, r, processingCard(*row))
		return
	}
	a.render(w, r, VideoCard(*row))
}

// startProcessing marks a video in-flight and summarizes it in the background.
// The goroutine uses a detached context — not the request's, which is cancelled
// when the handler returns — and clears the in-flight mark on completion. On
// error the DB flag stays set, so the video remains queued for the next
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
func (a *App) startProcessing(userID, videoID string) {
	key := processingKey(userID, videoID)
	a.Processing.Add(key)
	go func() {
		defer a.Processing.Remove(key)
		if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
			a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
		}
	}()
}

// handleStatus is the HTMX poll target for an in-flight summarization. It returns
// the card in its current state: the full summary card once the summary exists,
// otherwise the animated processing card while still in-flight (which keeps
// polling), or the queued/button card when neither holds. VideoCard carries no
// polling attributes, so HTMX stops polling once it swaps in.
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	videoID := r.PathValue("videoId")

	row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
	if errors.Is(err, store.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		a.serverError(w, r, "get video", err)
		return
	}

	// Honest, state-aware status (ADR-025). Order matters: a finished summary wins;
	// an in-flight goroutine shows the working spinner; a recorded rate-limit shows
	// the calm "waiting, will retry" card that keeps polling; a recorded "none" is
	// terminal; anything else falls back to the normal card.
	switch {
	case row.Summarized:
		a.render(w, r, VideoCard(*row))
	case a.Processing.Has(processingKey(userID, videoID)):
		a.render(w, r, processingCard(*row))
	case row.TranscriptStatus == "rate_limited":
		a.render(w, r, waitingCard(*row))
	case row.TranscriptStatus == "none":
		a.render(w, r, noCaptionsCard(*row))
	default:
		a.render(w, r, VideoCard(*row))
	}
}

// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
// submits the desired new value (enabled=true|false). For HTMX it returns the
// refreshed mode control; without JS it redirects back to the account page.
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
	userID, ok := a.currentUserID(w, r)
	if !ok {
		return
	}
	enabled := r.FormValue("enabled") == "true"
	if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
		a.serverError(w, r, "set summarize mode", err)
		return
	}
	if !isHTMX(r) {
		http.Redirect(w, r, "/account", http.StatusSeeOther)
		return
	}
	a.render(w, r, summarizeModeControl(enabled))
}

// currentUserID returns the tapir user_id the registration gate resolved for this
// request. Behind the gate it is always present; a miss means a handler was
// reached without scoping (a wiring bug), so it answers 500 and reports false.
func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
	id, ok := CurrentUserID(r)
	if !ok {
		a.serverError(w, r, "current user", errNoCurrentUser)
	}
	return id, ok
}

// render writes a templ component as HTML. A render error is logged, not retried:
// headers may already be flushed, so there is nothing useful to send the client.
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
	a.renderStatus(w, r, http.StatusOK, c)
}

// renderStatus writes a templ component as HTML with an explicit status code (the
// Content-Type must be set before WriteHeader, so this is the single place that
// orders them correctly).
func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	if err := c.Render(r.Context(), w); err != nil {
		a.logger().Error("render", "path", r.URL.Path, "err", err)
	}
}

func (a *App) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
	a.logger().Error("handler error", "op", op, "path", r.URL.Path, "err", err)
	http.Error(w, "internal error", http.StatusInternalServerError)
}

// isHTMX reports whether the request came from HTMX, which sets HX-Request: true.
func isHTMX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}
