package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// seedSession inserts a session for userID and returns its id.
func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID {
	t.Helper()
	token, err := auth.MintSessionToken()
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
		UserID:    userID,
		TokenHash: auth.HashSessionToken(token),
		UserAgent: "test-agent",
		Ip:        ip,
	})
	if err != nil {
		t.Fatalf("insert session: %v", err)
	}
	return sess.ID
}

// withSession attaches the user and current-session id the handlers expect
// from RequireUser.
func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request {
	ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user)
	ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID)
	return r.WithContext(ctx)
}

// withURLParam wires a chi route param, which handlers read via chi.URLParam.
func withURLParam(r *http.Request, key, value string) *http.Request {
	rctx := chi.NewRouteContext()
	rctx.URLParams.Add(key, value)
	return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}

// The rule #47 assertion. A delete keyed only on session id would let any
// household member revoke any other member's session by id — this pins that
// the user scope is actually in the WHERE clause and not just intended.
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
	h, pool := testHandlers(t)
	alice := seedUser(t, pool, "alice", "hunter2", false)
	bob := seedUser(t, pool, "bob", "hunter2", false)

	bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
	aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1")

	target := uuidToString(bobSession)
	req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
	req = withURLParam(req, "id", target)
	req = withSession(req, alice, aliceSession)
	w := httptest.NewRecorder()
	h.handleRevokeMySession(w, req)

	if w.Code != http.StatusNotFound {
		t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code)
	}

	// The 404 must mean "didn't happen", not merely "wasn't reported".
	var stillThere bool
	if err := pool.QueryRow(context.Background(),
		`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession,
	).Scan(&stillThere); err != nil {
		t.Fatalf("exists check: %v", err)
	}
	if !stillThere {
		t.Error("bob's session was deleted by alice's request")
	}
}

func TestRevokeMySession_DeletesOwnSession(t *testing.T) {
	h, pool := testHandlers(t)
	alice := seedUser(t, pool, "alice", "hunter2", false)
	current := seedSession(t, pool, alice.ID, "203.0.113.1")
	other := seedSession(t, pool, alice.ID, "198.51.100.7")

	target := uuidToString(other)
	req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
	req = withURLParam(req, "id", target)
	req = withSession(req, alice, current)
	w := httptest.NewRecorder()
	h.handleRevokeMySession(w, req)

	if w.Code != http.StatusNoContent {
		t.Fatalf("status = %d, want 204", w.Code)
	}
	var gone bool
	if err := pool.QueryRow(context.Background(),
		`SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other,
	).Scan(&gone); err != nil {
		t.Fatalf("exists check: %v", err)
	}
	if !gone {
		t.Error("session survived its own owner's revoke")
	}
}

// "Log out everywhere else" must spare the caller — otherwise the button
// signs you out of the page you pressed it on, which is indistinguishable
// from the compromise it's meant to remedy.
func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) {
	h, pool := testHandlers(t)
	alice := seedUser(t, pool, "alice", "hunter2", false)
	bob := seedUser(t, pool, "bob", "hunter2", false)

	current := seedSession(t, pool, alice.ID, "203.0.113.1")
	seedSession(t, pool, alice.ID, "198.51.100.7")
	seedSession(t, pool, alice.ID, "198.51.100.8")
	bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")

	req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
	req = withSession(req, alice, current)
	w := httptest.NewRecorder()
	h.handleRevokeMyOtherSessions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", w.Code)
	}
	var body revokedResp
	if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body.Revoked != 2 {
		t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked)
	}

	var aliceRemaining, bobRemaining int
	if err := pool.QueryRow(context.Background(),
		`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
	).Scan(&aliceRemaining); err != nil {
		t.Fatalf("count alice: %v", err)
	}
	if aliceRemaining != 1 {
		t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining)
	}
	if err := pool.QueryRow(context.Background(),
		`SELECT count(*) FROM sessions WHERE id = $1`, bobSession,
	).Scan(&bobRemaining); err != nil {
		t.Fatalf("count bob: %v", err)
	}
	if bobRemaining != 1 {
		t.Error("bob's session was caught in alice's logout-others")
	}
}

// Without a current-session id the exclusion has nothing to exclude, so the
// handler must refuse rather than delete everything.
func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) {
	h, pool := testHandlers(t)
	alice := seedUser(t, pool, "alice", "hunter2", false)
	seedSession(t, pool, alice.ID, "203.0.113.1")

	req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
	req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice))
	w := httptest.NewRecorder()
	h.handleRevokeMyOtherSessions(w, req)

	if w.Code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500", w.Code)
	}
	var remaining int
	if err := pool.QueryRow(context.Background(),
		`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
	).Scan(&remaining); err != nil {
		t.Fatalf("count: %v", err)
	}
	if remaining != 1 {
		t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining)
	}
}

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

	current := seedSession(t, pool, alice.ID, "203.0.113.1")
	seedSession(t, pool, alice.ID, "198.51.100.7")
	seedSession(t, pool, bob.ID, "203.0.113.9")

	req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil)
	req = withSession(req, alice, current)
	w := httptest.NewRecorder()
	h.handleListMySessions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", w.Code)
	}
	var got []sessionResp
	if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got))
	}
	currentCount := 0
	for _, s := range got {
		if s.Current {
			currentCount++
			if s.ID != uuidToString(current) {
				t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current))
			}
		}
		if s.CreatedIP == "" {
			t.Error("created_ip empty — the whole point of the surface")
		}
	}
	if currentCount != 1 {
		t.Errorf("current-flagged rows = %d, want exactly 1", currentCount)
	}
}
