package admin

import (
	"context"
	"crypto/rand"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/redis/go-redis/v9"
	"github.com/wangjia/pangolin/server/internal/totp"
)

type testEnv struct {
	router   http.Handler
	sessions *SessionStore
	store    *fakeStore
	codes    *fakeCodes
	life     *recordingLifecycle
	prov     *recordingProvision
	mr       *miniredis.Miniredis
	cfg      *Config
	key      []byte
}

func newEnv(t *testing.T, provReady, lifeReady bool) *testEnv {
	t.Helper()
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	t.Cleanup(func() { rdb.Close() })

	key := make([]byte, 32)
	if _, err := rand.Read(key); err != nil {
		t.Fatal(err)
	}
	allow, _ := ParseCIDRs([]string{"127.0.0.0/8"})
	cfg := &Config{
		Listen: "127.0.0.1:9443", AllowCIDRs: allow, SecretKey: key,
		SessionTTL: 30 * time.Minute, LoginFailMax: 3, LoginLockDuration: time.Minute,
		CookieSecure: false,
	}

	store := newFakeStore()
	store.nodes = []NodeRow{{ID: 7, UUID: "u7", Region: "HK", NameZH: "香港节点", Tier: "pro", Status: "up", Weight: 100, Provider: "p"}}
	codes := &fakeCodes{}
	life := &recordingLifecycle{ready: lifeReady}
	prov := &recordingProvision{ready: provReady}

	sessions := NewSessionStore(rdb, cfg.SessionTTL)
	sec := NewSecurityLog(store, nil)
	auth := NewAuthenticator(store, sessions, rdb, cfg, sec)
	svc := Services{Codes: codes, Lifecycle: life, Provision: prov}
	h, err := NewHandlers(cfg, store, sessions, auth, svc, sec, nil)
	if err != nil {
		t.Fatal(err)
	}
	return &testEnv{
		router:   NewRouter(h, sessions, cfg, sec),
		sessions: sessions, store: store, codes: codes, life: life, prov: prov,
		mr: mr, cfg: cfg, key: key,
	}
}

// login creates a session and returns the cookie and its CSRF token.
func (e *testEnv) login(t *testing.T) (*http.Cookie, string) {
	t.Helper()
	sid, sess, err := e.sessions.Create(context.Background(), 1, "alice")
	if err != nil {
		t.Fatal(err)
	}
	return &http.Cookie{Name: SessionCookieName, Value: sid}, sess.CSRFToken
}

func (e *testEnv) do(t *testing.T, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
	t.Helper()
	var req *http.Request
	if form != nil {
		req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	} else {
		req = httptest.NewRequest(method, target, nil)
	}
	req.RemoteAddr = "127.0.0.1:40000"
	if cookie != nil {
		req.AddCookie(cookie)
	}
	rec := httptest.NewRecorder()
	e.router.ServeHTTP(rec, req)
	return rec
}

// --------------------------------------------------------------------------

func TestRouter_IPBlocked(t *testing.T) {
	e := newEnv(t, true, true)
	req := httptest.NewRequest("GET", "/login", nil)
	req.RemoteAddr = "8.8.8.8:1234"
	rec := httptest.NewRecorder()
	e.router.ServeHTTP(rec, req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status %d; want 403", rec.Code)
	}
}

func TestRouter_UnauthRedirect(t *testing.T) {
	e := newEnv(t, true, true)
	rec := e.do(t, "GET", "/", nil, nil)
	if rec.Code != http.StatusFound {
		t.Fatalf("status %d; want 302", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/login" {
		t.Errorf("redirect to %q; want /login", loc)
	}
}

func TestLoginFlow_EndToEnd(t *testing.T) {
	e := newEnv(t, true, true)
	secret := newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass")
	code, _ := totp.Code(secret, time.Now().UTC())

	form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {code}}
	rec := e.do(t, "POST", "/login", form, nil)
	if rec.Code != http.StatusFound {
		t.Fatalf("login status %d; want 302", rec.Code)
	}
	var sc *http.Cookie
	for _, c := range rec.Result().Cookies() {
		if c.Name == SessionCookieName {
			sc = c
		}
	}
	if sc == nil {
		t.Fatal("no session cookie set")
	}
	if !sc.HttpOnly || sc.SameSite != http.SameSiteStrictMode {
		t.Errorf("cookie flags wrong: HttpOnly=%v SameSite=%v", sc.HttpOnly, sc.SameSite)
	}
	// Authenticated dashboard now reachable.
	rec2 := e.do(t, "GET", "/", nil, sc)
	if rec2.Code != http.StatusOK {
		t.Fatalf("dashboard status %d; want 200", rec2.Code)
	}
}

func TestLoginFlow_BadTOTPRejected(t *testing.T) {
	e := newEnv(t, true, true)
	_ = newTestAdmin(t, e.store, e.key, "alice", "s3cret-pass")
	form := url.Values{"username": {"alice"}, "password": {"s3cret-pass"}, "totp": {"000000"}}
	rec := e.do(t, "POST", "/login", form, nil)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status %d; want 401", rec.Code)
	}
	if len(rec.Result().Cookies()) != 0 {
		t.Error("cookie set despite failed login")
	}
}

func TestCreateBatch_CSVAndAudit(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, csrf := e.login(t)
	e.codes.nextCodes = []string{"PLAINCODE-A", "PLAINCODE-B"}

	form := url.Values{
		"csrf_token": {csrf}, "plan": {"pro"}, "duration_days": {"30"},
		"count": {"2"}, "channel": {"manual"}, "note": {"q2 promo"},
	}
	rec := e.do(t, "POST", "/codes", form, cookie)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d; want 200", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
		t.Errorf("content-type %q; want text/csv", ct)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "PLAINCODE-A") || !strings.Contains(body, "PLAINCODE-B") {
		t.Error("CSV missing plaintext codes")
	}
	// Service received the right params.
	if len(e.codes.created) != 1 {
		t.Fatalf("CreateBatch called %d times", len(e.codes.created))
	}
	got := e.codes.created[0]
	if got.Plan != "pro" || got.DurationDays != 30 || got.Count != 2 || got.Channel != "manual" {
		t.Errorf("batch params wrong: %+v", got)
	}
	if got.CreatedBy != "admin:alice" {
		t.Errorf("CreatedBy = %q; want admin:alice", got.CreatedBy)
	}
	// Audit written, but with NO plaintext leaked into meta.
	au := e.store.auditFor("code_batch_create")
	if len(au) != 1 {
		t.Fatalf("expected 1 code_batch_create audit, got %d", len(au))
	}
	if strings.Contains(au[0].Meta, "PLAINCODE") {
		t.Errorf("plaintext code leaked into audit meta: %s", au[0].Meta)
	}
}

func TestCreateBatch_BadCSRFRejected(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, _ := e.login(t)
	form := url.Values{"csrf_token": {"wrong"}, "plan": {"pro"}, "duration_days": {"30"}, "count": {"2"}, "channel": {"manual"}}
	rec := e.do(t, "POST", "/codes", form, cookie)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status %d; want 403", rec.Code)
	}
	if len(e.codes.created) != 0 {
		t.Error("batch created despite CSRF failure")
	}
}

func TestVoidBatch_RequiresConfirm(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, csrf := e.login(t)
	e.codes.voidReturn = 9

	// Missing confirm → 400.
	form := url.Values{"csrf_token": {csrf}, "batch_id": {"5"}}
	rec := e.do(t, "POST", "/codes/void", form, cookie)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("missing-confirm status %d; want 400", rec.Code)
	}
	if len(e.codes.voided) != 0 {
		t.Error("void executed without confirmation")
	}

	// With confirm → 302 + audit.
	form.Set("confirm", "yes")
	rec = e.do(t, "POST", "/codes/void", form, cookie)
	if rec.Code != http.StatusFound {
		t.Fatalf("confirmed void status %d; want 302", rec.Code)
	}
	if len(e.codes.voided) != 1 || e.codes.voided[0] != 5 {
		t.Errorf("voided = %v; want [5]", e.codes.voided)
	}
	if len(e.store.auditFor("code_batch_void")) != 1 {
		t.Error("void not audited")
	}
}

func TestNodeOp_Replace(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, csrf := e.login(t)
	form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}}
	rec := e.do(t, "POST", "/nodes/op", form, cookie)
	if rec.Code != http.StatusFound {
		t.Fatalf("status %d; want 302", rec.Code)
	}
	if len(e.prov.calls) != 1 || e.prov.calls[0].NodeID != 7 || e.prov.calls[0].Actor != "alice" {
		t.Errorf("provision calls = %+v; want [{7 alice}]", e.prov.calls)
	}
	if len(e.store.auditFor("node_replace")) != 1 {
		t.Error("replace not audited")
	}
}

func TestNodeOp_Draining(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, csrf := e.login(t)
	form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"draining"}, "confirm": {"yes"}}
	rec := e.do(t, "POST", "/nodes/op", form, cookie)
	if rec.Code != http.StatusFound {
		t.Fatalf("status %d; want 302", rec.Code)
	}
	if len(e.life.calls) != 1 || e.life.calls[0].NodeID != 7 || e.life.calls[0].Target != "draining" || e.life.calls[0].Actor != "alice" {
		t.Errorf("lifecycle calls = %+v", e.life.calls)
	}
	if len(e.store.auditFor("node_draining")) != 1 {
		t.Error("draining not audited")
	}
}

func TestNodeOp_MissingConfirmRejected(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, csrf := e.login(t)
	form := url.Values{"csrf_token": {csrf}, "node_id": {"7"}, "op": {"replace"}}
	rec := e.do(t, "POST", "/nodes/op", form, cookie)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status %d; want 400", rec.Code)
	}
	if len(e.prov.calls) != 0 {
		t.Error("replace executed without confirmation")
	}
}

func TestNodeOp_BadCSRFRejected(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, _ := e.login(t)
	form := url.Values{"csrf_token": {"nope"}, "node_id": {"7"}, "op": {"replace"}, "confirm": {"yes"}}
	rec := e.do(t, "POST", "/nodes/op", form, cookie)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("status %d; want 403", rec.Code)
	}
	if len(e.prov.calls) != 0 {
		t.Error("replace executed despite bad CSRF")
	}
}

func TestSessionExpiry_RequiresReLogin(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, _ := e.login(t)
	if rec := e.do(t, "GET", "/", nil, cookie); rec.Code != http.StatusOK {
		t.Fatalf("fresh session status %d; want 200", rec.Code)
	}
	// Idle past the 30-minute TTL.
	e.mr.FastForward(31 * time.Minute)
	rec := e.do(t, "GET", "/", nil, cookie)
	if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
		t.Fatalf("expired session: status %d loc %q; want 302 /login", rec.Code, rec.Header().Get("Location"))
	}
}

func TestNodesPage_Renders(t *testing.T) {
	e := newEnv(t, false, false)
	cookie, _ := e.login(t)
	rec := e.do(t, "GET", "/nodes", nil, cookie)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d; want 200", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "香港节点") {
		t.Error("node name not rendered")
	}
	// Stub services → controls greyed (disabled) + notice shown.
	if !strings.Contains(body, "disabled") {
		t.Error("expected disabled buttons when services not ready")
	}
}

func TestAuditPage_Filters(t *testing.T) {
	e := newEnv(t, true, true)
	cookie, _ := e.login(t)
	ctx := context.Background()
	_ = e.store.WriteAudit(ctx, "alice", "node_replace", "node:7", `{"op":"replace"}`)
	_ = e.store.WriteAudit(ctx, "bob", "code_batch_void", "batch:3", `{"voided":2}`)

	rec := e.do(t, "GET", "/audit?action=node_replace", nil, cookie)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d; want 200", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "node:7") {
		t.Error("filtered entry missing")
	}
	if strings.Contains(body, "batch:3") {
		t.Error("filter leaked non-matching entry")
	}
}
