package api

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/crypto/bcrypt"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/config"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
	"git.fabledsword.com/bvandeusen/minstrel/internal/library"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
	"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
	"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)

// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
// Skips in -short mode or when the env var is missing, matching the pattern
// used elsewhere (scanner_test.go, etc.).
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping api integration in -short mode")
	}
	dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("MINSTREL_TEST_DATABASE_URL not set")
	}
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	if err := db.Migrate(dsn, logger); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	pool, err := pgxpool.New(context.Background(), dsn)
	if err != nil {
		t.Fatalf("pool: %v", err)
	}
	t.Cleanup(pool.Close)
	dbtest.ResetDB(t, pool)
	w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
	recCfg := config.RecommendationConfig{
		RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
	}
	recSettings, err := recsettings.New(context.Background(), pool, logger)
	if err != nil {
		t.Fatalf("recsettings: %v", err)
	}
	lidarrCfg := lidarrconfig.New(pool)
	lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
	lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
	// tracks.Service has no Lidarr unmonitorer in tests by default; the
	// admin-tracks tests below override h.tracks via installTracksLidarrStub
	// when they need a stubbed Lidarr.
	dataDir := t.TempDir()
	tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
	playlistsSvc := playlists.NewService(pool, logger, dataDir)
	h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
	return h, pool
}

// seedUser creates a test user. The supplied username is automatically
// prefixed with dbtest.TestUserPrefix so dbtest.ResetDB cleans it up
// without touching the operator's admin row. Callers that need to send
// the username in HTTP bodies or compare against API responses must
// include the same prefix in their literals.
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
	t.Helper()
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
	if err != nil {
		t.Fatalf("bcrypt: %v", err)
	}
	prefixed := dbtest.TestUserPrefix + username
	u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
		Username:     prefixed,
		PasswordHash: string(hash),
		ApiToken:     "test-api-token-" + prefixed,
		IsAdmin:      isAdmin,
	})
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	return u
}

func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
	h, pool := testHandlers(t)
	seedUser(t, pool, "alice", "hunter2", false)

	body := strings.NewReader(`{"username":"test-alice","password":"hunter2"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}

	var resp struct {
		Token string `json:"token"`
		User  struct {
			Username string `json:"username"`
			IsAdmin  bool   `json:"is_admin"`
		} `json:"user"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
	}
	if resp.Token == "" {
		t.Error("token empty")
	}
	if resp.User.Username != "test-alice" || resp.User.IsAdmin {
		t.Errorf("user = %+v, want alice/non-admin", resp.User)
	}

	var cookieFound bool
	for _, c := range w.Result().Cookies() {
		if c.Name == auth.SessionCookieName {
			cookieFound = true
			if !c.HttpOnly {
				t.Error("session cookie missing HttpOnly")
			}
			if c.SameSite != http.SameSiteStrictMode {
				t.Errorf("SameSite = %v, want Strict", c.SameSite)
			}
			if c.Value != resp.Token {
				t.Error("cookie value does not match response token")
			}
		}
	}
	if !cookieFound {
		t.Error("session cookie not set")
	}
}

func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
	h, pool := testHandlers(t)
	seedUser(t, pool, "alice", "hunter2", false)

	body := strings.NewReader(`{"username":"test-alice","password":"wrong"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", w.Code)
	}
}

func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
	h, _ := testHandlers(t)
	body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", w.Code)
	}
}

func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
		bytes.NewReader([]byte("not-json")))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
}

func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }

func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "hunter2", false)

	// Manually create a session to log out of.
	token, err := auth.MintSessionToken()
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
		UserID:    user.ID,
		TokenHash: auth.HashSessionToken(token),
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
	req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
	// handleLogout runs behind RequireUser in real routing; simulate that by
	// putting the user into context here.
	req = withUser(req, user)
	w := httptest.NewRecorder()
	h.handleLogout(w, req)

	if w.Code != http.StatusNoContent {
		t.Errorf("status = %d, want 204", w.Code)
	}

	// Cookie should be cleared.
	var cleared bool
	for _, c := range w.Result().Cookies() {
		if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
			cleared = true
		}
	}
	if !cleared {
		t.Error("session cookie not cleared")
	}

	// Session row should be gone.
	_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
	if err == nil {
		t.Error("session row still present after logout")
	}
}

func TestHandleLogout_BearerHeaderWithTrailingWhitespaceDeletesSession(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "hunter2", false)

	token, err := auth.MintSessionToken()
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
		UserID:    user.ID,
		TokenHash: auth.HashSessionToken(token),
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
	// Trailing whitespace — RequireUser trims this, so logout must too.
	req.Header.Set("Authorization", "Bearer "+token+"  ")
	req = withUser(req, user)
	w := httptest.NewRecorder()
	h.handleLogout(w, req)

	if w.Code != http.StatusNoContent {
		t.Errorf("status = %d, want 204", w.Code)
	}

	_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
	if err == nil {
		t.Error("session row still present after bearer logout with trailing whitespace")
	}
}
