package httpapi

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-jose/go-jose/v4"
	"github.com/go-jose/go-jose/v4/jwt"

	"commilitia.net/cdrop/internal/config"
	"commilitia.net/cdrop/internal/db"
	"commilitia.net/cdrop/internal/hub"
	"commilitia.net/cdrop/internal/jwtauth"
)

const qrTestSecret = "qr-test-session-secret-at-least-32-bytes"

// newQRTestServer builds a Server with only the fields the scan-login handlers
// touch, backed by a fresh file-based sqlite (an in-memory DB would give each
// pooled connection its own empty schema).
func newQRTestServer(t *testing.T) *Server {
	t.Helper()
	conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db"))
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	if err := db.Bootstrap(context.Background(), conn); err != nil {
		t.Fatalf("bootstrap: %v", err)
	}
	q := db.New(conn)
	return &Server{
		cfg: &config.Config{
			AuthMode: "prod", SessionSecret: qrTestSecret,
			QRLoginEnabled: true, QRRequestTTLSeconds: 120,
			QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
			SessionTokenTTLSeconds: 900,
		},
		queries:         q,
		hub:             hub.New(q),
		sessionKey:      deriveSessionKey(qrTestSecret),
		sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
		siteOrigin:      "https://drop.example.net",
	}
}

func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) {
	t.Helper()
	body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName)
	r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body))
	w := httptest.NewRecorder()
	s.handleQRStart(w, r)
	if w.Code != http.StatusOK {
		t.Fatalf("qr/start: %d %s", w.Code, w.Body.String())
	}
	var resp qrStartResp
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode start: %v", err)
	}
	u, err := url.Parse(resp.QRPayload)
	if err != nil {
		t.Fatalf("qr_payload not a url: %v", err)
	}
	return resp, u.Query().Get("c")
}

func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int {
	t.Helper()
	body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist)
	r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body))
	r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"}))
	w := httptest.NewRecorder()
	s.handleQRApprove(w, r)
	return w.Code
}

func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder {
	r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil)
	r.Header.Set("X-Poll-Secret", pollSecret)
	w := httptest.NewRecorder()
	s.handleQRStatus(w, r)
	return w
}

func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp {
	t.Helper()
	var resp qrStatusResp
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode status: %v (%s)", err, w.Body.String())
	}
	return resp
}

// The happy path: a new device starts a request, the approver authorises it as a
// one-time guest, and the new device collects a live guest session — cookie set,
// access token minted, request single-use thereafter.
func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) {
	s := newQRTestServer(t)
	start, code := qrStart(t, s, "Borrowed Laptop")

	if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent {
		t.Fatalf("approve: got %d, want 204", c)
	}

	w := qrStatus(s, start.RequestID, start.PollSecret)
	if w.Code != http.StatusOK {
		t.Fatalf("status: %d %s", w.Code, w.Body.String())
	}
	got := decodeStatus(t, w)
	if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" {
		t.Fatalf("collected status wrong: %+v", got)
	}
	if got.ExpiresIn != 900 {
		t.Errorf("expires_in: got %d, want 900", got.ExpiresIn)
	}
	var hasCookie bool
	for _, ck := range w.Result().Cookies() {
		if ck.Name == sessionCookieName && ck.Value != "" {
			hasCookie = true
		}
	}
	if !hasCookie {
		t.Error("collection must set the session cookie on the new device's response")
	}

	// The minted token is a properly signed guest session token.
	if scope := selfTokenScope(t, got.AccessToken); scope != "guest" {
		t.Fatalf("minted token scope: got %q, want guest", scope)
	}

	// A guest (one-time) session row exists for the approver and is non-sliding.
	rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1")
	if err != nil || len(rows) != 1 {
		t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err)
	}
	if rows[0].Kind != "guest" || rows[0].Scope != "guest" {
		t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope)
	}

	// Single use: a second collection finds the request consumed.
	if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" {
		t.Errorf("second collection: got %q, want expired (consumed)", got2.Status)
	}
}

// A persistent ("trust this device") approval yields a sliding self session.
func TestQRFlow_PersistYieldsSelfSession(t *testing.T) {
	s := newQRTestServer(t)
	start, code := qrStart(t, s, "Home PC")
	if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent {
		t.Fatalf("approve: got %d, want 204", c)
	}
	if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
		t.Fatalf("collect: %+v", got)
	}
	rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2")
	if len(rows) != 1 || rows[0].Kind != "self" {
		t.Fatalf("persistent approval must create a kind=self session, got %+v", rows)
	}
}

// The poll_secret is the new device's only credential: a wrong one is rejected
// even for a real, approved request, so a QR photographer can't collect it.
func TestQRFlow_WrongPollSecretRejected(t *testing.T) {
	s := newQRTestServer(t)
	start, code := qrStart(t, s, "Laptop")
	_ = qrApprove(t, s, start.RequestID, code, "once", "approver-3")

	if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden {
		t.Errorf("wrong poll secret: got %d, want 403", w.Code)
	}
	// The real secret still works afterwards (the bad attempt didn't consume it).
	if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" {
		t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status)
	}
}

// Denial propagates to the polling device.
func TestQRFlow_Deny(t *testing.T) {
	s := newQRTestServer(t)
	start, code := qrStart(t, s, "Laptop")
	body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code)
	r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body))
	r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"}))
	w := httptest.NewRecorder()
	s.handleQRDeny(w, r)
	if w.Code != http.StatusNoContent {
		t.Fatalf("deny: got %d, want 204", w.Code)
	}
	if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" {
		t.Errorf("status after deny: got %q, want denied", got.Status)
	}
}

// With step-up enabled, approving without a fresh re-auth is refused (the gate
// rejects an empty step-up proof before any IdP call). AUTH.md §6.
func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) {
	s := newQRTestServer(t)
	s.cfg.StepUpEnabled = true
	start, code := qrStart(t, s, "Laptop")
	if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden {
		t.Errorf("approve without step-up proof: got %d, want 403", c)
	}
}

// A pending request long-polls then reports pending for the client to re-poll.
func TestQRFlow_PendingLongPoll(t *testing.T) {
	saved := qrStatusPollWindow
	qrStatusPollWindow = 50 * time.Millisecond
	defer func() { qrStatusPollWindow = saved }()

	s := newQRTestServer(t)
	start, _ := qrStart(t, s, "Laptop")
	w := qrStatus(s, start.RequestID, start.PollSecret)
	if got := decodeStatus(t, w); got.Status != "pending" {
		t.Errorf("pending long-poll: got %q, want pending", got.Status)
	}
}

// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device
// upsert happens without an X-Device-Name header).
type noopAuthStore struct{}

func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil }
func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) {
	return db.ShortcutToken{}, fmt.Errorf("none")
}
func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error {
	return nil
}
func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
	return db.WebSession{ID: id}, nil // session always live in these tests
}

// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a
// guest session token is rejected 403 on an account-management route, a full one
// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve
// devices / remove devices / mint tokens).
func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) {
	secret := "test-session-secret-at-least-32-bytes-ok"
	srv := &Server{
		cfg:             &config.Config{SessionTokenTTLSeconds: 900},
		sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret),
	}
	guest, _, err := srv.mintSessionToken("u", "sid", "guest")
	if err != nil {
		t.Fatalf("mint guest: %v", err)
	}
	full, _, err := srv.mintSessionToken("u", "sid", "full")
	if err != nil {
		t.Fatalf("mint full: %v", err)
	}

	a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{})
	handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	})))
	probe := func(tok string) int {
		r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil)
		r.Header.Set("Authorization", "Bearer "+tok)
		w := httptest.NewRecorder()
		handler.ServeHTTP(w, r)
		return w.Code
	}
	if c := probe(guest); c != http.StatusForbidden {
		t.Errorf("guest token on requireFullSession route: got %d, want 403", c)
	}
	if c := probe(full); c != http.StatusOK {
		t.Errorf("full token on requireFullSession route: got %d, want 200", c)
	}
}

// Revoking a session is sensitive: with step-up enabled it is refused (403
// step_up_required) until the caller's session has recently stepped up, then it
// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on
// the cookie session, so it is per-device (AUTH.md §1/§6).
func TestSessionRevoke_RequiresStepUp(t *testing.T) {
	s := newQRTestServer(t)
	s.cfg.StepUpEnabled = true
	s.cfg.StepUpMaxAgeSeconds = 300

	raw, id, err := newSessionToken()
	if err != nil {
		t.Fatalf("token: %v", err)
	}
	now := time.Now().Unix()
	if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{
		ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full",
		GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
	}); err != nil {
		t.Fatalf("create session: %v", err)
	}

	revoke := func() int {
		r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil)
		r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw})
		r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
			&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
		w := httptest.NewRecorder()
		s.handleSessionRevoke(w, r)
		return w.Code
	}

	// Not stepped up → blocked before any deletion.
	if c := revoke(); c != http.StatusForbidden {
		t.Fatalf("revoke without step-up: got %d, want 403", c)
	}
	// Record step-up on this (cookie) session → gate now passes (400 = missing id,
	// reached past the step-up check).
	if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{
		SteppedUpAt: now, ID: id,
	}); err != nil {
		t.Fatalf("set stepped up: %v", err)
	}
	if c := revoke(); c == http.StatusForbidden {
		t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at")
	}
}

// selfTokenScope verifies a self-signed session token under the test's session
// key (proving the signature) and returns its scope claim.
func selfTokenScope(t *testing.T, token string) string {
	t.Helper()
	parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
	if err != nil {
		t.Fatalf("parse token: %v", err)
	}
	var std jwt.Claims
	custom := map[string]any{}
	if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil {
		t.Fatalf("verify token signature: %v", err)
	}
	if typ, _ := custom["typ"].(string); typ != "session" {
		t.Fatalf("token typ: got %q, want session", typ)
	}
	scope, _ := custom["scope"].(string)
	return scope
}

// revokeByID drives handleSessionRevoke with the chi {id} param and full claims,
// step-up off (the gate is exercised separately).
func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder {
	r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("id", id)
	ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
	ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"})
	w := httptest.NewRecorder()
	s.handleSessionRevoke(w, r.WithContext(ctx))
	return w
}

// Revoking a web session removes its device registration too, so a revoked device
// disappears from the device list at once (the user can no longer remove devices
// by hand). AUTH.md §4.
func TestSessionRevoke_DeletesDevice(t *testing.T) {
	s := newQRTestServer(t) // step-up off by default
	ctx := context.Background()
	now := time.Now().Unix()

	_, id, err := newSessionToken()
	if err != nil {
		t.Fatalf("token: %v", err)
	}
	if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
		ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full",
		GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
	}); err != nil {
		t.Fatalf("create session: %v", err)
	}
	if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
		UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now,
	}); err != nil {
		t.Fatalf("upsert device: %v", err)
	}

	if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
		t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
	}
	if _, err := s.queries.GetWebSession(ctx, id); err == nil {
		t.Errorf("session still present after revoke")
	}
	devs, _ := s.queries.ListDevicesByUser(ctx, "u")
	for _, d := range devs {
		if d.Name == "Laptop" {
			t.Errorf("device 'Laptop' not deleted on session revoke")
		}
	}
}

// The session list unifies web_sessions with native client devices (desktop / iOS)
// that have no web_session, while never double-listing a device that already has a
// session and excluding shortcut-token devices. AUTH.md §4.
func TestSessionsList_IncludesNativeDevices(t *testing.T) {
	s := newQRTestServer(t)
	ctx := context.Background()
	now := time.Now().Unix()

	_, id, err := newSessionToken()
	if err != nil {
		t.Fatalf("token: %v", err)
	}
	if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
		ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full",
		GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
	}); err != nil {
		t.Fatalf("create session: %v", err)
	}
	for _, d := range []struct{ name, typ string }{
		{"Web", "browser"},        // has a web_session — must not be double-listed
		{"Desk", "macos"},         // native, no session — must appear as native
		{"iShortcut", "shortcut"}, // scoped token — excluded from session list
	} {
		if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
			UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
		}); err != nil {
			t.Fatalf("upsert device %s: %v", d.name, err)
		}
	}

	r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
	r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
		&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
	w := httptest.NewRecorder()
	s.handleSessionsList(w, r)
	if w.Code != http.StatusOK {
		t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String())
	}
	var resp struct {
		Sessions []sessionView `json:"sessions"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	var web, webNativeDup, desk, shortcut bool
	for _, v := range resp.Sessions {
		switch {
		case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc":
			web = true
		case v.DeviceName == "Web" && v.Native:
			webNativeDup = true
		case v.DeviceName == "Desk" && v.Native && v.Kind == "macos":
			desk = true
		case v.DeviceName == "iShortcut":
			shortcut = true
		}
	}
	if !web {
		t.Errorf("web session for 'Web' missing")
	}
	if webNativeDup {
		t.Errorf("'Web' double-listed as a native device")
	}
	if !desk {
		t.Errorf("native device 'Desk' missing from session list")
	}
	if shortcut {
		t.Errorf("shortcut device leaked into session list")
	}
}

// The sweeper drops browser devices whose web_session is gone (orphans) while
// keeping browser devices that still have a live session and all native devices.
func TestDeleteOrphanBrowserDevices(t *testing.T) {
	s := newQRTestServer(t)
	ctx := context.Background()
	now := time.Now().Unix()

	// Browser "Kept" has a live session; browser "Orphan" has none; native "Desk"
	// (macos) legitimately has no session and must survive.
	_, id, err := newSessionToken()
	if err != nil {
		t.Fatalf("token: %v", err)
	}
	if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
		ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full",
		CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
	}); err != nil {
		t.Fatalf("create session: %v", err)
	}
	for _, d := range []struct{ name, typ string }{
		{"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"},
	} {
		if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
			UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
		}); err != nil {
			t.Fatalf("upsert %s: %v", d.name, err)
		}
	}

	if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil {
		t.Fatalf("sweep: %v", err)
	}
	devs, _ := s.queries.ListDevicesByUser(ctx, "u")
	got := map[string]bool{}
	for _, d := range devs {
		got[d.Name] = true
	}
	if !got["Kept"] {
		t.Errorf("browser 'Kept' with a live session was wrongly swept")
	}
	if got["Orphan"] {
		t.Errorf("orphan browser 'Orphan' was not swept")
	}
	if !got["Desk"] {
		t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)")
	}
}

// Removing a native device (the session list's logout path for desktop / iOS) is
// sensitive and requires a recent step-up, mirroring web-session revocation.
func TestDeleteDevice_RequiresStepUp(t *testing.T) {
	s := newQRTestServer(t)
	s.cfg.StepUpEnabled = true
	s.cfg.StepUpMaxAgeSeconds = 300

	r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil)
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add("name", "Desk")
	ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
	ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"})
	w := httptest.NewRecorder()
	s.handleDeleteDevice(w, r.WithContext(ctx))
	if w.Code != http.StatusForbidden {
		t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
	}
}
