// Tests for the M13a W5 JWT gate in routerd. We test the gate
// wiring (which routes are protected, which roles are allowed)
// in isolation. The dedupe flush handler is exercised end-to-end
// against a real dedupe.Collapser (no broker needed; the onFlush
// callback is a no-op in the test).
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"

	"git3.techno-world.net/lrosales/broad-announce/internal/alert"
	"git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
)

const testSecret = "test-secret-with-32-bytes-min-len-abc"

// mintTestJWT signs a HS256 access token using the given secret
// and role. Mirrors the helper in cmd/admind/main_test.go — we
// don't go through authd.mintAccessToken because it's unexported.
func mintTestJWT(t *testing.T, secret, role string) string {
	t.Helper()
	hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
	body, _ := json.Marshal(map[string]any{
		"sub":  "u-test",
		"tid":  "t-test",
		"role": role,
		"typ":  "access",
		"iss":  "broad-announce",
		"exp":  time.Now().Add(15 * time.Minute).Unix(),
		"iat":  time.Now().Unix(),
	})
	enc := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(body)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(enc))
	sig := mac.Sum(nil)
	return enc + "." + base64.RawURLEncoding.EncodeToString(sig)
}

// newTestCollapser returns a Collapser wired to a no-op onFlush
// callback, plus the matching fanoutState. Used by the gate tests
// to construct the dependencies wireAdminRoutes needs.
func newTestCollapser() (*dedupe.Collapser, *fanoutState) {
	state := newFanoutState()
	noop := func(string, string, alert.Alert) {}
	c := dedupe.NewCollapser(50*time.Millisecond, noop)
	return c, state
}

func discardLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

// TestWireAdminRoutes_NoSecret_Disabled verifies that with
// BA_AUTHD_JWT_SECRET unset, NO admin routes are registered. The
// M-series LAN deploy path stays the same as before.
func TestWireAdminRoutes_NoSecret_Disabled(t *testing.T) {
	os.Unsetenv("BA_AUTHD_JWT_SECRET")
	mux := http.NewServeMux()
	c, state := newTestCollapser()
	wireAdminRoutes(mux, c, state, discardLogger())

	// Each route should NOT be registered. A 404 (the mux's
	// default for an unknown path) is the proof.
	for _, tc := range []struct{ method, path string }{
		{"GET", "/v1/admin/dedupe/state"},
		{"POST", "/v1/admin/dedupe/flush"},
	} {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
		if rr.Code != http.StatusNotFound {
			t.Errorf("%s %s with no secret: got %d, want 404 (route should be unregistered)",
				tc.method, tc.path, rr.Code)
		}
	}
}

// TestWireAdminRoutes_WithSecret_Gated verifies that with the
// secret set, both admin routes are 401 without a token.
func TestWireAdminRoutes_WithSecret_Gated(t *testing.T) {
	t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
	t.Setenv("BA_AUTHD_ISSUER", "broad-announce")
	mux := http.NewServeMux()
	c, state := newTestCollapser()
	wireAdminRoutes(mux, c, state, discardLogger())

	for _, tc := range []struct{ method, path string }{
		{"GET", "/v1/admin/dedupe/state"},
		{"POST", "/v1/admin/dedupe/flush"},
	} {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
		if rr.Code != http.StatusUnauthorized {
			t.Errorf("%s %s with secret+no-token: status = %d, want 401",
				tc.method, tc.path, rr.Code)
		}
	}
}

// TestAdminDedupeState_Auth verifies that an authenticated user
// can read the dedupe state. The endpoint returns JSON with
// pending_collapses and cached_target_lists counts (both 0 in
// the fresh-test scenario).
func TestAdminDedupeState_Auth(t *testing.T) {
	t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
	tok := mintTestJWT(t, testSecret, "super_admin")

	mux := http.NewServeMux()
	c, state := newTestCollapser()
	wireAdminRoutes(mux, c, state, discardLogger())

	req := httptest.NewRequest("GET", "/v1/admin/dedupe/state", nil)
	req.Header.Set("Authorization", "Bearer "+tok)
	rr := httptest.NewRecorder()
	mux.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
	}
	var body map[string]any
	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if _, ok := body["pending_collapses"]; !ok {
		t.Errorf("body missing pending_collapses: %+v", body)
	}
}

// TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush verifies the
// read/destructive split:
//   - GET /v1/admin/dedupe/state — any authenticated user
//   - POST /v1/admin/dedupe/flush — super_admin or tenant_admin
func TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush(t *testing.T) {
	t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
	mux := http.NewServeMux()
	c, state := newTestCollapser()
	wireAdminRoutes(mux, c, state, discardLogger())

	mintFor := func(role string) string {
		return mintTestJWT(t, testSecret, role)
	}

	cases := []struct {
		name   string
		role   string
		method string
		path   string
		want   int
	}{
		{"viewer-state", "viewer", "GET", "/v1/admin/dedupe/state", 200},
		{"viewer-flush-403", "viewer", "POST", "/v1/admin/dedupe/flush", 403},
		{"ta-flush-200", "tenant_admin", "POST", "/v1/admin/dedupe/flush", 200},
		{"sa-flush-200", "super_admin", "POST", "/v1/admin/dedupe/flush", 200},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			tok := mintFor(tc.role)
			req := httptest.NewRequest(tc.method, tc.path, nil)
			req.Header.Set("Authorization", "Bearer "+tok)
			rr := httptest.NewRecorder()
			mux.ServeHTTP(rr, req)
			if rr.Code != tc.want {
				t.Errorf("%s %s as %s: status = %d, want %d (body: %s)",
					tc.method, tc.path, tc.role, rr.Code, tc.want, rr.Body.String())
			}
		})
	}
}

// TestAdminDedupeFlush_DrainsPending exercises the flush handler
// against a real Collapser. We Observe one alert to arm a pending
// collapse, then POST /v1/admin/dedupe/flush and verify the
// pending count drops to 0.
func TestAdminDedupeFlush_DrainsPending(t *testing.T) {
	t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
	tok := mintTestJWT(t, testSecret, "super_admin")

	mux := http.NewServeMux()
	c, state := newTestCollapser()
	wireAdminRoutes(mux, c, state, discardLogger())

	// Arm one pending collapse. We use a long flush window so
	// the auto-flush doesn't fire before our test call.
	c2 := dedupe.NewCollapser(5*time.Second, func(string, string, alert.Alert) {})
	c2.Observe("src-test", "k1", alert.Alert{ID: "a1", SourceID: "src-test", DedupeKey: "k1"})
	if c2.Pending() != 1 {
		t.Fatalf("after Observe: Pending = %d, want 1", c2.Pending())
	}
	// Wire a fresh mux with c2 instead of c (the original).
	mux2 := http.NewServeMux()
	wireAdminRoutes(mux2, c2, state, discardLogger())

	req := httptest.NewRequest("POST", "/v1/admin/dedupe/flush", nil)
	req.Header.Set("Authorization", "Bearer "+tok)
	rr := httptest.NewRecorder()
	mux2.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
	}
	var body map[string]any
	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if got, _ := body["pending_before"].(float64); int(got) != 1 {
		t.Errorf("pending_before = %v, want 1", body["pending_before"])
	}
	if flushed, _ := body["flushed"].(bool); !flushed {
		t.Errorf("flushed = %v, want true", body["flushed"])
	}
}
