package probe_test

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

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

// --------------------------------------------------------------------------
// Test helpers
// --------------------------------------------------------------------------

const (
	testProbeID = "probe-test-01"
	testSecret  = "s3cr3t-for-testing"
)

// newTestSetup creates a fresh miniredis instance, a Store, and an
// IngestHandler pre-configured with testProbeID/testSecret.
// The miniredis server is automatically closed when the test ends.
func newTestSetup(t *testing.T) (*probe.IngestHandler, *probe.Store, *miniredis.Miniredis) {
	t.Helper()
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	st := probe.NewStore(rdb)
	reg := probe.NewMapRegistry(map[string]string{testProbeID: testSecret})
	h := probe.NewIngestHandler(reg, st)
	return h, st, mr
}

// sign computes the HMAC-SHA256 hex digest expected by IngestHandler.
// Message: ts + "\n" + rawBody
func sign(secret, tsStr string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(tsStr))
	mac.Write([]byte("\n"))
	mac.Write(body)
	return hex.EncodeToString(mac.Sum(nil))
}

// sampleRequest builds a minimal valid ReportRequest.
func sampleRequest() probe.ReportRequest {
	return probe.ReportRequest{
		ProbeID: testProbeID,
		Vantage: probe.VantagePoint{
			ISP:     "China Telecom",
			Region:  "Guangdong",
			Country: "CN",
		},
		Reports: []probe.NodeReport{
			{
				NodeID: "node-hk-01",
				Ts:     time.Now().Unix(),
				L1:     probe.L1Result{OK: true, RttMs: 42},
				L2: &probe.L2Result{
					OK:   true,
					ALPN: "h3",
				},
				L3: &probe.L3Result{OK: true},
			},
		},
	}
}

// buildHTTPRequest encodes req as JSON and attaches the three required auth
// headers with the current timestamp and a valid HMAC.
func buildHTTPRequest(t *testing.T, req probe.ReportRequest, overrideTS ...string) *http.Request {
	t.Helper()
	body, err := json.Marshal(req)
	if err != nil {
		t.Fatalf("marshal request: %v", err)
	}
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)
	if len(overrideTS) > 0 {
		tsStr = overrideTS[0]
	}
	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", tsStr)
	r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))
	return r
}

// --------------------------------------------------------------------------
// Auth failure tests (no Redis touch needed, but we still pass a valid setup)
// --------------------------------------------------------------------------

// TestIngestBadSignature verifies that a wrong HMAC returns 401.
func TestIngestBadSignature(t *testing.T) {
	h, _, _ := newTestSetup(t)

	req := sampleRequest()
	body, _ := json.Marshal(req)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", tsStr)
	// Deliberately wrong secret.
	r.Header.Set("X-Probe-Sign", sign("wrong-secret", tsStr, body))

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("bad signature: got %d, want 401", w.Code)
	}
}

// TestIngestMissingHeaders verifies that missing any of the three auth headers
// results in 401.
func TestIngestMissingHeaders(t *testing.T) {
	h, _, _ := newTestSetup(t)
	req := sampleRequest()
	body, _ := json.Marshal(req)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)
	validSign := sign(testSecret, tsStr, body)

	cases := []struct {
		name   string
		id     string
		ts     string
		sigHdr string
	}{
		{"no X-Probe-Id", "", tsStr, validSign},
		{"no X-Probe-Ts", testProbeID, "", validSign},
		{"no X-Probe-Sign", testProbeID, tsStr, ""},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
			if tc.id != "" {
				r.Header.Set("X-Probe-Id", tc.id)
			}
			if tc.ts != "" {
				r.Header.Set("X-Probe-Ts", tc.ts)
			}
			if tc.sigHdr != "" {
				r.Header.Set("X-Probe-Sign", tc.sigHdr)
			}
			w := httptest.NewRecorder()
			h.ServeHTTP(w, r)
			if w.Code != http.StatusUnauthorized {
				t.Errorf("%s: got %d, want 401", tc.name, w.Code)
			}
		})
	}
}

// TestIngestUnknownProbeID verifies that an unregistered probe ID returns 401.
func TestIngestUnknownProbeID(t *testing.T) {
	h, _, _ := newTestSetup(t)
	req := sampleRequest()
	body, _ := json.Marshal(req)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("X-Probe-Id", "not-registered")
	r.Header.Set("X-Probe-Ts", tsStr)
	r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("unknown probe: got %d, want 401", w.Code)
	}
}

// TestIngestTimestampExpired verifies that a stale timestamp (> 300s old)
// returns 401.
func TestIngestTimestampExpired(t *testing.T) {
	h, _, _ := newTestSetup(t)
	req := sampleRequest()
	body, _ := json.Marshal(req)

	// 10 minutes in the past – well outside the ±300 s window.
	staleTS := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", staleTS)
	r.Header.Set("X-Probe-Sign", sign(testSecret, staleTS, body))

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("stale ts: got %d, want 401", w.Code)
	}
}

// TestIngestTimestampFuture verifies that a far-future timestamp is also
// rejected (outside the ±300 s window).
func TestIngestTimestampFuture(t *testing.T) {
	h, _, _ := newTestSetup(t)
	req := sampleRequest()
	body, _ := json.Marshal(req)

	futureTS := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", futureTS)
	r.Header.Set("X-Probe-Sign", sign(testSecret, futureTS, body))

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("future ts: got %d, want 401", w.Code)
	}
}

// TestIngestHMACConstantTime verifies that an HMAC of correct length but with
// one byte flipped is still rejected.  This guards against a non-constant-time
// comparison that might accept a zero-byte suffix match.
func TestIngestHMACConstantTime(t *testing.T) {
	h, _, _ := newTestSetup(t)
	req := sampleRequest()
	body, _ := json.Marshal(req)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)

	// Produce a valid MAC then flip the last byte.
	mac := hmac.New(sha256.New, []byte(testSecret))
	mac.Write([]byte(tsStr))
	mac.Write([]byte("\n"))
	mac.Write(body)
	correctMAC := mac.Sum(nil)
	correctMAC[len(correctMAC)-1] ^= 0xFF
	tamperedSign := hex.EncodeToString(correctMAC)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", tsStr)
	r.Header.Set("X-Probe-Sign", tamperedSign)

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("tampered HMAC: got %d, want 401", w.Code)
	}
}

// --------------------------------------------------------------------------
// Integration tests – valid requests + Redis state
// --------------------------------------------------------------------------

// TestIngestValidReport is the full happy-path integration test.
// After a valid POST /probe/report the handler must:
//  1. Return 202 Accepted.
//  2. Write probe:{nodeID}:{vantage} in Redis with TTL ≈ 30 min.
//  3. Write probe:hb:{probeID} in Redis with TTL ≈ 15 min.
func TestIngestValidReport(t *testing.T) {
	h, _, mr := newTestSetup(t)

	req := sampleRequest()
	httpReq := buildHTTPRequest(t, req)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httpReq)

	if w.Code != http.StatusAccepted {
		t.Fatalf("valid report: got %d, want 202; body=%s", w.Code, w.Body.String())
	}

	// --- Verify Redis snapshot key ---
	// Expected key: probe:node-hk-01:CN:Guangdong:China_Telecom
	snapKey := "probe:node-hk-01:CN:Guangdong:China_Telecom"
	val, err := mr.Get(snapKey)
	if err != nil {
		t.Fatalf("snapshot key %q not found in Redis: %v", snapKey, err)
	}

	var snap probe.ProbeSnapshot
	if err := json.Unmarshal([]byte(val), &snap); err != nil {
		t.Fatalf("unmarshal snapshot: %v", err)
	}
	if snap.ProbeID != testProbeID {
		t.Errorf("snapshot.ProbeID = %q, want %q", snap.ProbeID, testProbeID)
	}
	if snap.Report.NodeID != "node-hk-01" {
		t.Errorf("snapshot.Report.NodeID = %q, want node-hk-01", snap.Report.NodeID)
	}
	if !snap.Report.L1.OK {
		t.Error("snapshot L1.OK should be true")
	}
	if snap.ReceivedAt == 0 {
		t.Error("snapshot.ReceivedAt should be non-zero")
	}

	// TTL should be close to 30 minutes (1800s).  Allow ±5s for test latency.
	snapTTL := mr.TTL(snapKey)
	if snapTTL < 1795*time.Second || snapTTL > 1800*time.Second {
		t.Errorf("snapshot TTL = %v, want ≈30 min", snapTTL)
	}

	// --- Verify heartbeat key ---
	hbKey := "probe:hb:" + testProbeID
	hbVal, err := mr.Get(hbKey)
	if err != nil {
		t.Fatalf("heartbeat key %q not found in Redis: %v", hbKey, err)
	}
	if hbVal == "" {
		t.Error("heartbeat value should not be empty")
	}

	// TTL should be close to 15 minutes (900s).
	hbTTL := mr.TTL(hbKey)
	if hbTTL < 895*time.Second || hbTTL > 900*time.Second {
		t.Errorf("heartbeat TTL = %v, want ≈15 min", hbTTL)
	}
}

// TestIngestReplayIdempotent verifies that submitting the same (probeID, ts)
// a second time is treated as an idempotent replay:
//   - First request: 202, Redis written.
//   - Second request: 202, no error (no double-write).
func TestIngestReplayIdempotent(t *testing.T) {
	h, _, mr := newTestSetup(t)

	req := sampleRequest()
	body, _ := json.Marshal(req)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)

	buildReq := func() *http.Request {
		r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
		r.Header.Set("Content-Type", "application/json")
		r.Header.Set("X-Probe-Id", testProbeID)
		r.Header.Set("X-Probe-Ts", tsStr) // same ts both times
		r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))
		return r
	}

	// First request.
	w1 := httptest.NewRecorder()
	h.ServeHTTP(w1, buildReq())
	if w1.Code != http.StatusAccepted {
		t.Fatalf("first request: got %d, want 202", w1.Code)
	}

	// Snapshot key must exist after first request.
	snapKey := "probe:node-hk-01:CN:Guangdong:China_Telecom"
	if _, err := mr.Get(snapKey); err != nil {
		t.Fatalf("snapshot key missing after first request: %v", err)
	}

	// Second request (same ts – replay).
	w2 := httptest.NewRecorder()
	h.ServeHTTP(w2, buildReq())
	if w2.Code != http.StatusAccepted {
		t.Fatalf("replay request: got %d, want 202 (idempotent)", w2.Code)
	}

	// Replay prevention key should exist.
	seenKey := "probe:seen:" + testProbeID + ":" + tsStr
	if _, err := mr.Get(seenKey); err != nil {
		t.Errorf("seen key %q not found: %v", seenKey, err)
	}
}

// TestIngestMultipleReports verifies that a batch with multiple node reports
// writes one snapshot key per node.
func TestIngestMultipleReports(t *testing.T) {
	h, _, mr := newTestSetup(t)

	req := probe.ReportRequest{
		ProbeID: testProbeID,
		Vantage: probe.VantagePoint{Country: "CN", Region: "Shanghai", ISP: "ChinaNet"},
		Reports: []probe.NodeReport{
			{NodeID: "node-sg-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true, RttMs: 80}},
			{NodeID: "node-jp-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: false}},
		},
	}
	httpReq := buildHTTPRequest(t, req)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httpReq)

	if w.Code != http.StatusAccepted {
		t.Fatalf("multi-report: got %d, want 202", w.Code)
	}

	for _, nodeID := range []string{"node-sg-01", "node-jp-01"} {
		key := "probe:" + nodeID + ":CN:Shanghai:ChinaNet"
		if _, err := mr.Get(key); err != nil {
			t.Errorf("snapshot for %s not found: %v", nodeID, err)
		}
	}
}

// TestIngestL3Optional verifies that a report without L3 (third-party probe)
// is accepted normally.
func TestIngestL3Optional(t *testing.T) {
	h, _, _ := newTestSetup(t)

	req := probe.ReportRequest{
		ProbeID: testProbeID,
		Vantage: probe.VantagePoint{Country: "JP", Region: "Tokyo", ISP: "NTT"},
		Reports: []probe.NodeReport{
			{
				NodeID: "node-jp-02",
				Ts:     time.Now().Unix(),
				L1:     probe.L1Result{OK: true, RttMs: 15},
				L2:     &probe.L2Result{OK: true, ALPN: "h3"},
				// L3 intentionally absent.
			},
		},
	}
	httpReq := buildHTTPRequest(t, req)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httpReq)

	if w.Code != http.StatusAccepted {
		t.Errorf("L3-absent report: got %d, want 202", w.Code)
	}
}

// --------------------------------------------------------------------------
// Store unit tests
// --------------------------------------------------------------------------

// TestStoreSnapshotsByNode verifies that SnapshotsByNode returns the correct
// map and that a missing key (no data) is represented as an empty map, not
// as an error or failure.
func TestStoreSnapshotsByNode(t *testing.T) {
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	st := probe.NewStore(rdb)
	ctx := context.Background()

	// No data yet – must return empty map, not error.
	snaps, err := st.SnapshotsByNode(ctx, "node-xx-01")
	if err != nil {
		t.Fatalf("SnapshotsByNode with no data: unexpected error: %v", err)
	}
	if len(snaps) != 0 {
		t.Errorf("expected empty map, got %d entries", len(snaps))
	}

	// Write one snapshot via SaveReports.
	reports := []probe.NodeReport{
		{NodeID: "node-xx-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true, RttMs: 10}},
	}
	if err := st.SaveReports(ctx, "probe-a", probe.VantagePoint{Country: "SG", Region: "Central", ISP: "Singtel"}, reports); err != nil {
		t.Fatalf("SaveReports: %v", err)
	}

	snaps, err = st.SnapshotsByNode(ctx, "node-xx-01")
	if err != nil {
		t.Fatalf("SnapshotsByNode after write: %v", err)
	}
	if len(snaps) != 1 {
		t.Fatalf("expected 1 snapshot, got %d", len(snaps))
	}
	// The vantage key in the map should be Country:Region:ISP.
	if _, ok := snaps["SG:Central:Singtel"]; !ok {
		t.Errorf("expected key SG:Central:Singtel in snapshots; got keys: %v", mapKeys(snaps))
	}
}

// TestStoreAliveProbes verifies that AliveProbes returns probes that have an
// active heartbeat and an empty slice (not an error) when none have reported.
func TestStoreAliveProbes(t *testing.T) {
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	st := probe.NewStore(rdb)
	ctx := context.Background()

	// No heartbeats yet – empty list, not error.
	ids, err := st.AliveProbes(ctx)
	if err != nil {
		t.Fatalf("AliveProbes with no data: unexpected error: %v", err)
	}
	if len(ids) != 0 {
		t.Errorf("expected empty list, got %v", ids)
	}

	// Write a heartbeat for two probes.
	for _, pid := range []string{"probe-a", "probe-b"} {
		reports := []probe.NodeReport{
			{NodeID: "node-xx-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true}},
		}
		if err := st.SaveReports(ctx, pid, probe.VantagePoint{Country: "HK"}, reports); err != nil {
			t.Fatalf("SaveReports(%s): %v", pid, err)
		}
	}

	ids, err = st.AliveProbes(ctx)
	if err != nil {
		t.Fatalf("AliveProbes after writes: %v", err)
	}
	if len(ids) != 2 {
		t.Errorf("expected 2 alive probes, got %d: %v", len(ids), ids)
	}
}

// TestStoreMissingHeartbeatIsNotFailure documents the "missing key = no data,
// not failure" contract for probe:hb:{probeID}.
func TestStoreMissingHeartbeatIsNotFailure(t *testing.T) {
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	st := probe.NewStore(rdb)
	ctx := context.Background()

	// AliveProbes on an empty Redis must NOT return an error or non-nil slice.
	ids, err := st.AliveProbes(ctx)
	if err != nil {
		t.Fatalf("AliveProbes must not error on empty Redis: %v", err)
	}
	// An empty/nil slice is acceptable; a non-nil error is not.
	// Callers (15D) must treat this as "unknown", not "all probes down".
	_ = ids // zero-length is fine
}

// --------------------------------------------------------------------------
// Helper
// --------------------------------------------------------------------------

func mapKeys(m map[string]probe.ProbeSnapshot) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

// TestIngestMethodNotAllowed verifies GET returns 405.
func TestIngestMethodNotAllowed(t *testing.T) {
	h, _, _ := newTestSetup(t)
	r := httptest.NewRequest(http.MethodGet, "/probe/report", nil)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)
	if w.Code != http.StatusMethodNotAllowed {
		t.Errorf("GET: got %d, want 405", w.Code)
	}
}

// TestIngestEmptyReports verifies that a request with zero reports is rejected
// with 400.
func TestIngestEmptyReports(t *testing.T) {
	h, _, _ := newTestSetup(t)

	req := probe.ReportRequest{
		ProbeID: testProbeID,
		Vantage: probe.VantagePoint{Country: "CN", Region: "BJ", ISP: "Unicom"},
		Reports: []probe.NodeReport{}, // empty
	}
	httpReq := buildHTTPRequest(t, req)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httpReq)

	if w.Code != http.StatusBadRequest {
		t.Errorf("empty reports: got %d, want 400", w.Code)
	}
}

// TestVantageKeyEncoding verifies that vantage values with spaces and colons
// are sanitized and produce a valid Redis key.
func TestVantageKeyEncoding(t *testing.T) {
	h, _, mr := newTestSetup(t)

	req := probe.ReportRequest{
		ProbeID: testProbeID,
		Vantage: probe.VantagePoint{
			ISP:     "China Telecom:CN2", // contains space and colon
			Region:  "Inner Mongolia",
			Country: "CN",
		},
		Reports: []probe.NodeReport{
			{NodeID: "node-bj-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true}},
		},
	}
	httpReq := buildHTTPRequest(t, req)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httpReq)

	if w.Code != http.StatusAccepted {
		t.Fatalf("vantage encoding: got %d, want 202", w.Code)
	}

	// Space → _ and colon → _ in the ISP field.
	expectedKey := "probe:node-bj-01:CN:Inner_Mongolia:China_Telecom_CN2"
	if _, err := mr.Get(expectedKey); err != nil {
		// List all keys to help diagnose.
		keys := mr.Keys()
		t.Errorf("expected key %q not found; existing keys: %v", expectedKey, keys)
	}
}

// TestIngestSignatureUsesRawBody verifies that the HMAC is computed over the
// exact raw bytes sent on the wire, not a re-serialised form.
func TestIngestSignatureUsesRawBody(t *testing.T) {
	h, _, _ := newTestSetup(t)

	// Build raw body with extra whitespace (valid JSON, different bytes).
	rawBody := []byte(`{  "probeId": "` + testProbeID + `",  "vantage":{"isp":"ISP1","region":"R1","country":"CN"},  "reports":[{"nodeId":"n1","ts":1234567890,"l1":{"ok":true,"rttMs":1}}]  }`)
	tsStr := strconv.FormatInt(time.Now().Unix(), 10)

	r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(rawBody))
	r.Header.Set("Content-Type", "application/json")
	r.Header.Set("X-Probe-Id", testProbeID)
	r.Header.Set("X-Probe-Ts", tsStr)
	r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, rawBody))

	w := httptest.NewRecorder()
	h.ServeHTTP(w, r)

	if w.Code != http.StatusAccepted {
		t.Errorf("raw body sign: got %d, want 202; body=%s", w.Code, w.Body.String())
	}
}
