package web_test

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	embeddedpostgres "github.com/fergusstrange/embedded-postgres"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/stretchr/testify/require"

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

// dsn points at the in-process Postgres started in TestMain. Handler tests run
// against the real store (real SQL, the lane-A actions) behind StubAuth — the
// full read/write path minus live Dex.
var dsn string

func TestMain(m *testing.M) {
	// Per-process port + dirs so concurrent `go test` runs (e.g. a push-run and a
	// tag-run in CI) never collide on a fixed port or shared data dir. Base 55000
	// keeps web's range distinct from the store package (54000). Shared CachePath
	// downloads the PG archive once.
	port := uint32(55000 + os.Getpid()%1000)
	dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)

	rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-web-%d", os.Getpid()))
	pg := embeddedpostgres.NewDatabase(
		embeddedpostgres.DefaultConfig().
			Port(port).
			RuntimePath(rt).
			DataPath(filepath.Join(rt, "data")).
			BinariesPath(filepath.Join(rt, "bin")).
			CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
	)
	if err := pg.Start(); err != nil {
		fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
		os.Exit(1)
	}
	code := m.Run()
	if err := pg.Stop(); err != nil {
		fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
	}
	_ = os.RemoveAll(rt)
	os.Exit(code)
}

const (
	userID = "11111111-1111-1111-1111-111111111111"
	videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
	videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
	// stubSubject is the StubAuth Dex subject the registration gate resolves to
	// the fixed userID (mapping seeded by resetDB).
	stubSubject = "stub-subject-xyz"
)

func newStore(t *testing.T) *store.Store {
	t.Helper()
	s, err := store.New(context.Background(), dsn)
	require.NoError(t, err)
	t.Cleanup(s.Close)
	return s
}

func rawPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	p, err := pgxpool.New(context.Background(), dsn)
	require.NoError(t, err)
	t.Cleanup(p.Close)
	return p
}

// truncateAll wipes every table to a pristine state (user_identities is cleared
// via the ON DELETE CASCADE from users). Registration tests use this directly so
// no subject is pre-registered.
func truncateAll(t *testing.T, p *pgxpool.Pool) {
	t.Helper()
	_, err := p.Exec(context.Background(),
		`TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
	require.NoError(t, err)
}

// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
// the registration gate resolves the stub user and the existing handler tests can
// keep seeding and scoping by the fixed userID.
func resetDB(t *testing.T, p *pgxpool.Pool) {
	t.Helper()
	truncateAll(t, p)
	ctx := context.Background()
	_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
	require.NoError(t, err)
	_, err = p.Exec(ctx,
		`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
		 ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
	require.NoError(t, err)
}

// newApp builds the App under test as the registered stub user (subject
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
// minus Dex: the store is both the Store and the Identity port.
func newApp(t *testing.T) *web.App {
	t.Helper()
	return newAppAs(t, stubSubject)
}

// newAppAs builds the App under test with a specific StubAuth Dex subject, so
// registration-gate tests can drive registered vs unregistered subjects.
func newAppAs(t *testing.T, subject string) *web.App {
	t.Helper()
	s := newStore(t)
	return &web.App{
		Store:    s,
		Identity: s,
		Auth:     web.StubAuth{U: web.User{Subject: subject}},
	}
}

func summary(videoID, text string) domain.Summary {
	return domain.Summary{
		UserID:     userID,
		VideoID:    videoID,
		Summary:    text,
		Highlights: []string{"highlight one", "highlight two"},
		Takeaways:  []string{"takeaway one"},
		AIProvider: "local",
		AIModel:    "phi4-mini",
	}
}

func seedVideo(t *testing.T, p *pgxpool.Pool, videoID, title, url string, published time.Time) {
	t.Helper()
	var pub any
	if !published.IsZero() {
		pub = published
	}
	_, err := p.Exec(context.Background(),
		`INSERT INTO videos (id, user_id, provider, provider_video_id, title, url, published_at)
		 VALUES ($1, $2, 'youtube', $3, $4, $5, $6)`,
		videoID, userID, "pv-"+videoID[:8], title, url, pub)
	require.NoError(t, err)
}

func do(t *testing.T, app *web.App, req *http.Request) *httptest.ResponseRecorder {
	t.Helper()
	rec := httptest.NewRecorder()
	app.Router().ServeHTTP(rec, req)
	return rec
}

func body(t *testing.T, rec *httptest.ResponseRecorder) string {
	t.Helper()
	b, err := io.ReadAll(rec.Body)
	require.NoError(t, err)
	return string(b)
}

func TestHealthzNoAuth(t *testing.T) {
	app := newApp(t)
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/healthz", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	require.Equal(t, "ok", body(t, rec))
}

func TestListRendersRowsAndActionState(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)

	require.NoError(t, app.Store.SetAction(ctx, userID, videoX, "watched")) // action exists pre-summary
	require.NoError(t, deliver(ctx, app, videoX, "body x"))
	require.NoError(t, deliver(ctx, app, videoY, "body y"))
	seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
	seedVideo(t, p, videoY, "Y Title", "https://y", time.Time{})

	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)

	require.Contains(t, html, "<html", "full page on a non-HTMX request")
	require.Contains(t, html, "X Title")
	require.Contains(t, html, "Y Title")
	require.Contains(t, html, "youtube")
	require.Contains(t, html, "watched", "current action state shown in the list")
	require.Contains(t, html, "2026-01-01")
	require.Contains(t, html, `class="card"`, "rows render as cards, not a table")
}

func TestListHTMXReturnsFragment(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	require.NoError(t, deliver(ctx, app, videoX, "body x"))
	seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	req.Header.Set("HX-Request", "true")
	rec := do(t, app, req)

	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)
	require.Contains(t, html, `class="cards"`)
	require.NotContains(t, html, "<html", "HTMX request gets only the list fragment")
}

func TestListChannelFilter(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	require.NoError(t, deliver(ctx, app, videoX, "body x"))
	seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
	_, err := p.Exec(ctx, `UPDATE videos SET channel_title = 'Acme Channel' WHERE id = $1`, videoX)
	require.NoError(t, err)

	// Selecting a different channel hides the row; selecting its channel shows it.
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=Other+Channel", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	require.NotContains(t, body(t, rec), "X Title")

	rec = do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=Acme+Channel", nil))
	require.Contains(t, body(t, rec), "X Title")
}

func TestDetailRendersHighlightsAndTakeaways(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	require.NoError(t, deliver(ctx, app, videoX, "the full summary body"))
	seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))

	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)

	require.Contains(t, html, "the full summary body")
	require.Contains(t, html, "highlight one")
	require.Contains(t, html, "takeaway one")
	require.Contains(t, html, `id="action-buttons"`, "action button group present")
	require.Contains(t, html, "Watched")
	require.Contains(t, html, `class="segmented"`, "watched/skipped render as a segmented control (UX review C5)")
	require.Less(t, strings.Index(html, "Skipped"), strings.Index(html, "Saved"),
		"Saved sits after the watched/skipped segment")
	require.Contains(t, html, "← Summaries", "back link to the list (UX review C3)")

	// The detail page leads with the attention-saving payload (UX review A8):
	// Takeaways ("is it worth my time?") above Highlights above the full Summary.
	takeaways := strings.Index(html, "takeaway one")
	highlights := strings.Index(html, "highlight one")
	summary := strings.Index(html, "the full summary body")
	require.Less(t, takeaways, highlights, "Takeaways render before Highlights")
	require.Less(t, highlights, summary, "Highlights render before the full Summary")
}

func TestDetailNotFound(t *testing.T) {
	app := newApp(t)
	resetDB(t, rawPool(t))
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
	require.Equal(t, http.StatusNotFound, rec.Code)
}

func TestActionToggleReturnsFragment(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	resetDB(t, rawPool(t))
	require.NoError(t, deliver(ctx, app, videoX, "body x"))

	// First click: sets "watched", returns the fragment with it active.
	rec := postAction(t, app, videoX, "watched", true)
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)
	require.Contains(t, html, `id="action-buttons"`)
	require.Contains(t, html, "✓ Watched", "active verb marked")

	got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
	require.NoError(t, err)
	require.Equal(t, map[string][]string{videoX: {"watched"}}, got)

	// Second click on the same verb: clears it.
	rec = postAction(t, app, videoX, "watched", true)
	require.Equal(t, http.StatusOK, rec.Code)
	require.NotContains(t, body(t, rec), "✓ Watched", "re-click cleared the action")

	got, err = app.Store.ActionsFor(ctx, userID, []string{videoX})
	require.NoError(t, err)
	require.Empty(t, got, "action cleared in the store")
}

func TestActionNonHTMXRedirects(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	resetDB(t, rawPool(t))
	require.NoError(t, deliver(ctx, app, videoX, "body x"))

	rec := postAction(t, app, videoX, "saved", false)
	require.Equal(t, http.StatusSeeOther, rec.Code)
	require.Equal(t, "/v/"+videoX, rec.Header().Get("Location"))

	got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
	require.NoError(t, err)
	require.Equal(t, map[string][]string{videoX: {"saved"}}, got, "action persisted on the no-JS path")
}

func TestActionRejectsUnknownVerb(t *testing.T) {
	app := newApp(t)
	resetDB(t, rawPool(t))
	rec := postAction(t, app, videoX, "bookmarked", true)
	require.Equal(t, http.StatusBadRequest, rec.Code)
}

func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	// A discovered-but-unsummarized video (no summary delivered).
	seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})

	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)

	require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
	require.Contains(t, html, "Summarize", "a Summarize button is offered")
	require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
	require.Contains(t, html, "card-pending", "muted pending treatment")
	require.NotContains(t, html, "Queued", "not queued yet")
}

// TestListCollapsesOlderAndNoCaption verifies the feed IA (UX review B3/B4):
// summarized + recent un-summarized cards lead inline; older un-summarized
// videos collapse into a single disclosure; caption-less videos collapse into a
// one-line count instead of dead cards.
func TestListCollapsesOlderAndNoCaption(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
	app.RecencyWindow = 7 * 24 * time.Hour
	app.Now = func() time.Time { return now }
	p := rawPool(t)
	resetDB(t, p)

	const (
		vRecent = "cccccccc-cccc-cccc-cccc-cccccccccccc" // 2d old, unsummarized → inline
		vOld    = "dddddddd-dddd-dddd-dddd-dddddddddddd" // 30d old, unsummarized → disclosure
		vNoCap  = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" // caption-less → collapsed line
	)
	require.NoError(t, deliver(ctx, app, videoX, "body x")) // summarized, recent
	seedVideo(t, p, videoX, "Summarized X", "https://x", now.Add(-24*time.Hour))
	seedVideo(t, p, vRecent, "Recent Pending", "https://r", now.Add(-2*24*time.Hour))
	seedVideo(t, p, vOld, "Old Pending", "https://o", now.Add(-30*24*time.Hour))
	seedVideo(t, p, vNoCap, "No Caption Vid", "https://n", now.Add(-40*24*time.Hour))
	_, err := p.Exec(ctx, `UPDATE videos SET transcript_status = 'none' WHERE id = $1`, vNoCap)
	require.NoError(t, err)

	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)

	disclosure := strings.Index(html, "Show 1 older videos")
	require.GreaterOrEqual(t, disclosure, 0, "older-videos disclosure present")
	// Summarized + recent un-summarized lead inline, above the disclosure.
	require.Less(t, strings.Index(html, "Summarized X"), disclosure, "summarized card is inline")
	require.Less(t, strings.Index(html, "Recent Pending"), disclosure, "recent pending is inline")
	// The older video is hidden inside the disclosure, after its summary.
	require.Greater(t, strings.Index(html, "Old Pending"), disclosure, "older video lives in the disclosure")
	// Caption-less video is a one-line count, never a card.
	require.Contains(t, html, "have no captions")
	require.NotContains(t, html, "No Caption Vid", "caption-less video is collapsed, not a card")
}

// TestListHidesFilterBarWhenEmpty: a genuinely empty list shows no filter bar
// (the connect CTA stands alone), but a filter that matches nothing still shows
// the bar so it can be cleared (UX review C1).
func TestListHidesFilterBarWhenEmpty(t *testing.T) {
	app := newApp(t)
	resetDB(t, rawPool(t))

	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	require.NotContains(t, body(t, rec), `class="filters"`, "no filter bar on an empty account")

	rec = do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=nope", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	require.Contains(t, body(t, rec), `class="filters"`, "filtered-to-empty keeps the bar so it can be cleared")
}

func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})

	rec := postSummarize(t, app, videoX, true)
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)
	require.Contains(t, html, "Queued", "card now shows the queued state")
	require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")

	// The flag is persisted, so the next run picks it up.
	row, err := app.Store.GetVideoRow(ctx, userID, videoX)
	require.NoError(t, err)
	require.True(t, row.SummarizeRequested)
}

func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})

	rec := postSummarize(t, app, videoX, false)
	require.Equal(t, http.StatusSeeOther, rec.Code)
	require.Equal(t, "/", rec.Header().Get("Location"))

	row, err := app.Store.GetVideoRow(ctx, userID, videoX)
	require.NoError(t, err)
	require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
}

func TestRequestSummarizeNotFound(t *testing.T) {
	app := newApp(t)
	resetDB(t, rawPool(t))
	rec := postSummarize(t, app, videoX, true)
	require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
}

func TestSummarizeModeToggle(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	resetDB(t, rawPool(t))

	// Account page defaults to automatic (ADR-018: onboarded users get
	// zero-friction discovery — the list fills and summarizes itself).
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
	require.Equal(t, http.StatusOK, rec.Code)
	html := body(t, rec)
	require.Contains(t, html, "Automatic", "default mode shown")
	require.Contains(t, html, "Switch to manual")

	// Toggle to manual via HTMX returns the refreshed control.
	req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
		strings.NewReader("enabled=false"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("HX-Request", "true")
	rec = do(t, app, req)
	require.Equal(t, http.StatusOK, rec.Code)
	html = body(t, rec)
	require.Contains(t, html, "Manual")
	require.Contains(t, html, "Switch to automatic")

	got, err := app.Store.GetAutoSummarize(ctx, userID)
	require.NoError(t, err)
	require.False(t, got, "mode persisted")
}

func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
	if htmx {
		req.Header.Set("HX-Request", "true")
	}
	return do(t, app, req)
}

// deliver stores a summary through the App's store under test.
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
	return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
}

func postAction(t *testing.T, app *web.App, videoID, action string, htmx bool) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/action",
		strings.NewReader("action="+action))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if htmx {
		req.Header.Set("HX-Request", "true")
	}
	return do(t, app, req)
}

// TestListManualModeBannerCopy: a manual-mode user with un-summarized videos
// sees the manual prompt (click Summarize), NOT the "summaries land
// automatically" copy that misled the first pilot user into waiting forever.
func TestListManualModeBannerCopy(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) // pending, un-summarized
	require.NoError(t, app.Store.SetAutoSummarize(ctx, userID, false))

	html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)))
	require.Contains(t, html, "Manual mode")
	require.Contains(t, html, "are not summarized automatically")
	require.NotContains(t, html, "land gradually",
		"manual-mode user must not be told summaries arrive automatically")
}

// TestListAutoModeBannerCopy: an auto-mode user with a backlog sees the
// gradual-delivery copy, not the manual prompt.
func TestListAutoModeBannerCopy(t *testing.T) {
	ctx := context.Background()
	app := newApp(t)
	p := rawPool(t)
	resetDB(t, p)
	seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
	require.NoError(t, app.Store.SetAutoSummarize(ctx, userID, true))

	html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)))
	require.Contains(t, html, "land gradually")
	require.NotContains(t, html, "are not summarized automatically")
}

// TestMetricsNotOnPublicMux: the public app router exposes no /metrics route —
// Prometheus is served on the dedicated metrics port only (ADR-030, security R6).
func TestMetricsNotOnPublicMux(t *testing.T) {
	app := newApp(t)
	resetDB(t, rawPool(t))
	rec := do(t, app, httptest.NewRequest(http.MethodGet, "/metrics", nil))
	require.Equal(t, http.StatusNotFound, rec.Code, "/metrics must not be on the public mux")
}
