package jwtauth

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

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

type fakeDeviceStore struct {
	touched []db.TouchDeviceParams
}

func (f *fakeDeviceStore) TouchDevice(_ context.Context, arg db.TouchDeviceParams) error {
	f.touched = append(f.touched, arg)
	return nil
}

// runMiddleware drives a request through the auth middleware and captures the claims
// the downstream handler sees (nil if the request was rejected before reaching it).
func runMiddleware(a *Authenticator, r *http.Request) (*httptest.ResponseRecorder, *Claims) {
	var captured *Claims
	h := a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, rr *http.Request) {
		if c, ok := ClaimsFromContext(rr.Context()); ok {
			captured = c
		}
		w.WriteHeader(http.StatusOK)
	}))
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	return w, captured
}

func TestMiddleware_ProdReadsAuthHeaders(t *testing.T) {
	a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	r.Header.Set("X-Auth-Subject", "user-1")
	r.Header.Set("X-Auth-Scope", "app:cdrop:guest")
	r.Header.Set("X-Auth-Meta", "dev_abc")
	r.Header.Set("X-Auth-Name", "Alice")
	r.Header.Set("X-Auth-Roles", "admin, user")

	w, c := runMiddleware(a, r)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d, want 200", w.Code)
	}
	if c == nil {
		t.Fatal("claims missing from context")
	}
	if c.UserID != "user-1" || c.DeviceID != "dev_abc" || c.Name != "Alice" {
		t.Errorf("claims wrong: %+v", c)
	}
	if !c.Guest() {
		t.Error("app:cdrop:guest scope should mark Guest()")
	}
	if len(c.Groups) != 2 || c.Groups[0] != "admin" || c.Groups[1] != "user" {
		t.Errorf("groups: got %v", c.Groups)
	}
}

func TestMiddleware_ProdMissingSubjectRejected(t *testing.T) {
	a := New(&config.Config{AuthMode: "prod"}, &fakeDeviceStore{})
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	w, c := runMiddleware(a, r)
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("no X-Auth-Subject: got %d, want 401", w.Code)
	}
	if c != nil {
		t.Error("handler must not run on rejected request")
	}
}

func TestMiddleware_TouchesManagedDevice(t *testing.T) {
	fs := &fakeDeviceStore{}
	a := New(&config.Config{AuthMode: "prod"}, fs)
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	r.Header.Set("X-Auth-Subject", "user-1")
	r.Header.Set("X-Auth-Scope", "app:cdrop:full")
	r.Header.Set("X-Auth-Meta", "dev_x")
	runMiddleware(a, r)
	if len(fs.touched) != 1 {
		t.Fatalf("touch count: got %d, want 1", len(fs.touched))
	}
	if fs.touched[0].DeviceID != "dev_x" || fs.touched[0].Tier != "full" || fs.touched[0].UserID != "user-1" {
		t.Errorf("touch params: %+v", fs.touched[0])
	}
}

func TestMiddleware_SkipsTouchWhenUnmanaged(t *testing.T) {
	fs := &fakeDeviceStore{}
	a := New(&config.Config{AuthMode: "prod"}, fs)
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	r.Header.Set("X-Auth-Subject", "user-1")
	r.Header.Set("X-Auth-Scope", "full")
	// no X-Auth-Meta → unmanaged caller (e.g. a global SSO browser)
	runMiddleware(a, r)
	if len(fs.touched) != 0 {
		t.Errorf("unmanaged caller should not touch a device row; got %d", len(fs.touched))
	}
}

func TestMiddleware_DevMode(t *testing.T) {
	a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	r.Header.Set("Authorization", "Bearer devtok")
	r.Header.Set("X-Dev-User", "dev-alice")
	r.Header.Set("X-Dev-Scope", "guest")
	w, c := runMiddleware(a, r)
	if w.Code != http.StatusOK || c == nil {
		t.Fatalf("dev auth failed: %d", w.Code)
	}
	if c.UserID != "dev-alice" || !c.Guest() {
		t.Errorf("dev claims wrong: %+v", c)
	}
}

func TestMiddleware_DevModeBadToken(t *testing.T) {
	a := New(&config.Config{AuthMode: "dev", DevToken: "devtok"}, &fakeDeviceStore{})
	r := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	r.Header.Set("Authorization", "Bearer wrong")
	w, _ := runMiddleware(a, r)
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("bad dev token: got %d, want 401", w.Code)
	}
}

func TestClaimsTier(t *testing.T) {
	cases := []struct {
		scope string
		tier  string
		guest bool
	}{
		{"app:cdrop:guest", "guest", true},
		{"app:cdrop:full", "full", false},
		{"full", "full", false},
		{"app:cdrop", "cdrop", false},
		{"", "", false},
	}
	for _, tc := range cases {
		c := &Claims{Scope: tc.scope}
		if c.Tier() != tc.tier {
			t.Errorf("Tier(%q): got %q, want %q", tc.scope, c.Tier(), tc.tier)
		}
		if c.Guest() != tc.guest {
			t.Errorf("Guest(%q): got %v, want %v", tc.scope, c.Guest(), tc.guest)
		}
	}
}

func TestSanitizeDeviceName(t *testing.T) {
	if got := SanitizeDeviceName("  Alice's Mac  "); got != "Alice's Mac" {
		t.Errorf("trim: got %q", got)
	}
	// Non-ASCII is stripped (the name rides an HTTP header).
	if got := SanitizeDeviceName("名字abc"); got != "abc" {
		t.Errorf("non-ascii strip: got %q", got)
	}
}

func TestNormalizeDeviceType(t *testing.T) {
	for _, in := range []string{"macos", "windows", "linux", "ios", "browser"} {
		if got := normalizeDeviceType(in); got != in {
			t.Errorf("normalizeDeviceType(%q): got %q", in, got)
		}
	}
	if got := normalizeDeviceType("rogue"); got != "browser" {
		t.Errorf("unknown type should fall back to browser; got %q", got)
	}
}
