package auth

import (
	"context"
	"database/sql"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"testing"
	"testing/fstest"
	"time"

	dbpkg "github.com/operator/command-center/internal/db"

	_ "modernc.org/sqlite"
)

func openAuthDB(t *testing.T) *sql.DB {
	t.Helper()
	dbPath := filepath.Join(t.TempDir(), "test.db")
	db, err := dbpkg.OpenSQLite(dbPath)
	if err != nil {
		t.Fatalf("OpenSQLite: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })

	migs, err := dbpkg.LoadMigrations(fstest.MapFS{
		"003_phase2_auth.sql": {Data: []byte(`
			CREATE TABLE sessions (
				token TEXT PRIMARY KEY,
				created_at INTEGER NOT NULL,
				expires_at INTEGER NOT NULL,
				last_seen_at INTEGER NOT NULL,
				user_agent TEXT,
				ip_address TEXT
			);
			CREATE TABLE recovery_codes (
				id INTEGER PRIMARY KEY AUTOINCREMENT,
				code_hash BLOB NOT NULL,
				used_at INTEGER,
				created_at INTEGER NOT NULL
			);
			CREATE TABLE webauthn_credentials (
				id TEXT PRIMARY KEY,
				public_key BLOB NOT NULL,
				counter INTEGER NOT NULL,
				transports TEXT,
				name TEXT,
				created_at INTEGER NOT NULL,
				last_used_at INTEGER
			);
		`)},
	})
	if err != nil {
		t.Fatalf("LoadMigrations: %v", err)
	}
	if _, err := dbpkg.Apply(db, migs); err != nil {
		t.Fatalf("Apply: %v", err)
	}
	return db
}

func TestSessionIssueAndValidate(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 0) // defaults
	ctx := context.Background()

	sess, err := s.Issue(ctx, "test-agent", "127.0.0.1")
	if err != nil {
		t.Fatalf("Issue: %v", err)
	}
	if sess.Token == "" {
		t.Fatal("empty token")
	}

	w := httptest.NewRecorder()
	s.SetCookie(w, sess)
	cookies := w.Result().Cookies()
	if len(cookies) != 1 || cookies[0].Name != "cc_session" {
		t.Fatalf("cookie missing or wrong name: %+v", cookies)
	}
	if !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteStrictMode {
		t.Errorf("cookie attrs wrong: %+v", cookies[0])
	}

	r := httptest.NewRequest("GET", "/", nil)
	r.AddCookie(cookies[0])
	got, err := s.Validate(ctx, r)
	if err != nil {
		t.Fatalf("Validate: %v", err)
	}
	if got.Token != sess.Token {
		t.Errorf("token mismatch")
	}
}

func TestSessionValidateMissingCookie(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 0)
	r := httptest.NewRequest("GET", "/", nil)
	if _, err := s.Validate(context.Background(), r); err != ErrSession {
		t.Errorf("got %v, want ErrSession", err)
	}
}

func TestSessionValidateTamperedToken(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 0)

	r := httptest.NewRequest("GET", "/", nil)
	r.AddCookie(&http.Cookie{Name: "cc_session", Value: "not-a-real-token!!"})
	if _, err := s.Validate(context.Background(), r); err != ErrSession {
		t.Errorf("got %v, want ErrSession", err)
	}

	r = httptest.NewRequest("GET", "/", nil)
	// Right shape, wrong value: 43-char base64url-looking string with no DB row.
	r.AddCookie(&http.Cookie{
		Name:  "cc_session",
		Value: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
	})
	if _, err := s.Validate(context.Background(), r); err != ErrSession {
		t.Errorf("got %v, want ErrSession (unknown token)", err)
	}
}

func TestSessionValidateExpired(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", time.Second)
	ctx := context.Background()

	sess, _ := s.Issue(ctx, "ua", "ip")
	// Force expire by editing the row.
	if _, err := db.ExecContext(ctx,
		`UPDATE sessions SET expires_at = ? WHERE token = ?`, time.Now().Add(-time.Second).Unix(), sess.Token,
	); err != nil {
		t.Fatalf("force-expire: %v", err)
	}

	r := httptest.NewRequest("GET", "/", nil)
	r.AddCookie(&http.Cookie{Name: "cc_session", Value: sess.Token})
	if _, err := s.Validate(ctx, r); err != ErrSession {
		t.Errorf("got %v, want ErrSession", err)
	}
	// Row should be cleaned up.
	var n int
	_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE token = ?`, sess.Token).Scan(&n)
	if n != 0 {
		t.Errorf("expired session not cleaned up: %d rows remain", n)
	}
}

func TestSessionRevoke(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 0)
	ctx := context.Background()

	sess, _ := s.Issue(ctx, "", "")
	if err := s.Revoke(ctx, sess.Token); err != nil {
		t.Fatalf("Revoke: %v", err)
	}
	r := httptest.NewRequest("GET", "/", nil)
	r.AddCookie(&http.Cookie{Name: "cc_session", Value: sess.Token})
	if _, err := s.Validate(ctx, r); err != ErrSession {
		t.Errorf("Validate after Revoke: %v, want ErrSession", err)
	}
}

func TestSessionRenewsOnActivity(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 24*time.Hour)
	ctx := context.Background()

	sess, _ := s.Issue(ctx, "", "")

	// Shrink the row's expires_at to "1 hour from now". Validate should
	// slide it back out to ~24 hours from now.
	shrunkExpires := time.Now().Add(1 * time.Hour).Unix()
	if _, err := db.ExecContext(ctx,
		`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token = ?`,
		time.Now().Add(-1*time.Hour).Unix(), shrunkExpires, sess.Token,
	); err != nil {
		t.Fatalf("rewind: %v", err)
	}

	r := httptest.NewRequest("GET", "/", nil)
	r.AddCookie(&http.Cookie{Name: "cc_session", Value: sess.Token})
	got, err := s.Validate(ctx, r)
	if err != nil {
		t.Fatalf("Validate: %v", err)
	}
	if got.ExpiresAt.Unix() <= shrunkExpires {
		t.Errorf("expected expires_at to slide past %d, got %d", shrunkExpires, got.ExpiresAt.Unix())
	}
}

func TestPurgeExpired(t *testing.T) {
	db := openAuthDB(t)
	s := NewSessionStore(db, "", 0)
	ctx := context.Background()

	sess, _ := s.Issue(ctx, "", "")
	_, _ = db.ExecContext(ctx, `UPDATE sessions SET expires_at = ? WHERE token = ?`,
		time.Now().Add(-1*time.Hour).Unix(), sess.Token)

	n, err := s.PurgeExpired(ctx)
	if err != nil {
		t.Fatalf("PurgeExpired: %v", err)
	}
	if n != 1 {
		t.Errorf("PurgeExpired returned %d, want 1", n)
	}
}
