package server

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"sync"
	"testing"
	"time"

	"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
	"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
	"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
)

// --- Mock Store ---

type mockStore struct {
	storetest.BaseMock
	mu              sync.Mutex
	sites           []models.SiteConfig
	alerts          []models.AlertConfig
	nodes           map[string]models.ProbeNode
	importedData    *models.Backup
	registeredNodes []models.ProbeNode
	maintWindows    []models.MaintenanceWindow
}

func newMockStore() *mockStore {
	return &mockStore{
		nodes: make(map[string]models.ProbeNode),
	}
}

func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil }
func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
	return m.alerts, nil
}

func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) {
	return models.Backup{
		Sites:  m.sites,
		Alerts: m.alerts,
	}, nil
}

func (m *mockStore) ImportData(_ context.Context, data models.Backup) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.importedData = &data
	return nil
}

func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.registeredNodes = append(m.registeredNodes, node)
	m.nodes[node.ID] = node
	return nil
}

func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if n, ok := m.nodes[id]; ok {
		return n, nil
	}
	return models.ProbeNode{}, fmt.Errorf("not found")
}

func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) {
	return m.maintWindows, nil
}

// --- Helpers ---

func freePort(t *testing.T) int {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("freePort: %v", err)
	}
	port := ln.Addr().(*net.TCPAddr).Port
	ln.Close()
	return port
}

type testServer struct {
	baseURL string
	srv     *http.Server
	store   *mockStore
	engine  *monitor.Engine
}

func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServer {
	t.Helper()
	ms := newMockStore()
	eng := monitor.NewEngine(ms)
	port := freePort(t)

	srv := Start(ServerConfig{
		Port:         port,
		EnableStatus: enableStatus,
		Title:        "Test Status",
		ClusterKey:   clusterKey,
	}, ms, eng)

	ts := &testServer{
		baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
		srv:     srv,
		store:   ms,
		engine:  eng,
	}

	// Wait for server to be ready
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		resp, err := http.Get(ts.baseURL + "/api/health")
		if err == nil {
			resp.Body.Close()
			break
		}
		time.Sleep(10 * time.Millisecond)
	}

	t.Cleanup(func() {
		srv.Close()
	})

	return ts
}

func authReq(method, url, secret string, body []byte) (*http.Response, error) {
	var req *http.Request
	var err error
	if body != nil {
		req, err = http.NewRequest(method, url, bytes.NewReader(body))
	} else {
		req, err = http.NewRequest(method, url, nil)
	}
	if err != nil {
		return nil, err
	}
	if secret != "" {
		req.Header.Set("X-Uptop-Secret", secret)
	}
	return http.DefaultClient.Do(req)
}

// --- Tests ---

func TestCheckSecret(t *testing.T) {
	if !checkSecret("mykey", "mykey") {
		t.Error("expected match")
	}
	if checkSecret("mykey", "wrong") {
		t.Error("expected no match")
	}
	if checkSecret("", "key") {
		t.Error("expected no match for empty got")
	}
}

// --- Push Heartbeat ---

func TestPush_MissingToken(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := http.Get(ts.baseURL + "/api/push")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 400 {
		t.Errorf("expected 400, got %d", resp.StatusCode)
	}
}

func TestPush_InvalidToken(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := http.Get(ts.baseURL + "/api/push?token=bad")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 404 {
		t.Errorf("expected 404, got %d", resp.StatusCode)
	}
}

// --- Health ---

func TestHealth_NoSecret(t *testing.T) {
	ts := newTestServer(t, "", false)
	resp, err := http.Get(ts.baseURL + "/api/health")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200 with no cluster key, got %d", resp.StatusCode)
	}
}

func TestHealth_ValidSecret(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/health", "secret", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

func TestHealth_WrongSecret(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/health", "wrong", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
	}
}

// --- Backup Export ---

func TestExport_Unauthorized_NoKey(t *testing.T) {
	ts := newTestServer(t, "", false)
	resp, err := http.Get(ts.baseURL + "/api/backup/export")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Errorf("expected 401 when no cluster key configured, got %d", resp.StatusCode)
	}
}

func TestExport_Unauthorized_WrongKey(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "wrong", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

func TestExport_Success(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	ts.store.sites = []models.SiteConfig{{ID: 1, Name: "example", URL: "http://example.com"}}

	resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	var backup models.Backup
	json.NewDecoder(resp.Body).Decode(&backup)
	if len(backup.Sites) != 1 {
		t.Errorf("expected 1 site, got %d", len(backup.Sites))
	}
}

// --- Backup Import ---

func TestImport_MethodNotAllowed(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/backup/import", "secret", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 405 {
		t.Errorf("expected 405, got %d", resp.StatusCode)
	}
}

func TestImport_Unauthorized(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(models.Backup{})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

func TestImport_Success(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	backup := models.Backup{
		Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}},
	}
	body, err := json.Marshal(backup)
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	ts.store.mu.Lock()
	defer ts.store.mu.Unlock()
	if ts.store.importedData == nil {
		t.Error("expected import data to be stored")
	}
}

func TestImport_InvalidJSON(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", []byte("not json"))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 400 {
		t.Errorf("expected 400, got %d", resp.StatusCode)
	}
}

// --- Probe Registration ---

func TestProbeRegister_Success(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(map[string]string{
		"id": "node-1", "name": "US East", "region": "us-east",
	})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	ts.store.mu.Lock()
	defer ts.store.mu.Unlock()
	if len(ts.store.registeredNodes) != 1 {
		t.Errorf("expected 1 registered node, got %d", len(ts.store.registeredNodes))
	}
	if ts.store.registeredNodes[0].ID != "node-1" {
		t.Errorf("expected node-1, got %s", ts.store.registeredNodes[0].ID)
	}
}

func TestProbeRegister_MissingID(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(map[string]string{"name": "test"})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 400 {
		t.Errorf("expected 400, got %d", resp.StatusCode)
	}
}

func TestProbeRegister_Unauthorized(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(map[string]string{"id": "node-1"})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

// --- Probe Results ---

func TestProbeResults_Success(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(map[string]any{
		"node_id": "node-1",
		"results": []map[string]any{
			{"site_id": 1, "latency_ns": 5000000, "is_up": true},
		},
	})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

func TestProbeResults_MissingNodeID(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	body, err := json.Marshal(map[string]any{
		"results": []map[string]any{},
	})
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 400 {
		t.Errorf("expected 400, got %d", resp.StatusCode)
	}
}

// --- Status Page ---

func TestStatusPage_Enabled(t *testing.T) {
	ts := newTestServer(t, "secret", true)
	resp, err := http.Get(ts.baseURL + "/status")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

func TestStatusJSON_PublicDTOOnly(t *testing.T) {
	ts := newTestServer(t, "secret", true)

	// Seed a push monitor (no network IO) through the store and start the
	// engine so its poll loop loads it into live state — the path real sites
	// take. The old version of this test injected via UpdateSiteConfig, which
	// no-ops for unknown IDs, so it asserted over zero sites and passed
	// against a server that leaked tokens.
	ts.store.sites = []models.SiteConfig{{
		ID: 1, Name: "test", Type: "push", Token: "secret-token",
		Hostname: "internal-host", AlertID: 3,
	}}
	ctx, cancel := context.WithCancel(context.Background())
	ts.engine.Start(ctx)
	t.Cleanup(func() {
		cancel()
		ts.engine.Stop()
	})

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) && len(ts.engine.GetLiveState()) == 0 {
		time.Sleep(10 * time.Millisecond)
	}
	if len(ts.engine.GetLiveState()) == 0 {
		t.Fatal("engine never loaded the seeded site")
	}

	resp, err := http.Get(ts.baseURL + "/status/json")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}

	// Decode raw so absent struct fields can't mask leaked JSON keys.
	var state map[string]map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
		t.Fatal(err)
	}
	if len(state) != 1 {
		t.Fatalf("expected 1 site in status JSON, got %d", len(state))
	}
	for _, site := range state {
		if site["Name"] != "test" {
			t.Errorf("expected Name to be public, got %v", site["Name"])
		}
		for _, leaked := range []string{"Token", "LastError", "Hostname", "Port", "DNSServer", "AlertID", "AcceptedCodes", "Interval"} {
			if _, ok := site[leaked]; ok {
				t.Errorf("status JSON leaks internal field %q", leaked)
			}
		}
	}
}

func TestStatusJSON_MaintenanceOverride(t *testing.T) {
	ts := newTestServer(t, "secret", true)
	ts.store.maintWindows = []models.MaintenanceWindow{
		{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-1 * time.Hour)},
	}

	resp, err := http.Get(ts.baseURL + "/status/json")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

func TestStatusPage_Disabled(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := http.Get(ts.baseURL + "/status")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 404 {
		t.Errorf("expected 404 when status disabled, got %d", resp.StatusCode)
	}
}

// --- Probe Assignments ---

func TestProbeAssignments_Success(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "secret", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	var result map[string][]models.Site
	json.NewDecoder(resp.Body).Decode(&result)
	if _, ok := result["sites"]; !ok {
		t.Error("expected 'sites' key in response")
	}
}

func TestProbeAssignments_Unauthorized(t *testing.T) {
	ts := newTestServer(t, "secret", false)
	resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "wrong", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 401 {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

// --- Security: X-Forwarded-For trusted-proxy handling ---

func mustCIDR(t *testing.T, s string) *net.IPNet {
	t.Helper()
	_, n, err := net.ParseCIDR(s)
	if err != nil {
		t.Fatalf("ParseCIDR(%q): %v", s, err)
	}
	return n
}

func TestClientIP_TrustedProxyHandling(t *testing.T) {
	trusted := []*net.IPNet{mustCIDR(t, "10.0.0.0/8")}

	tests := []struct {
		name       string
		remoteAddr string
		xff        string
		trusted    []*net.IPNet
		want       string
	}{
		{"no trusted proxies ignores XFF", "203.0.113.9:5000", "1.2.3.4", nil, "203.0.113.9"},
		{"untrusted peer ignores XFF", "203.0.113.9:5000", "1.2.3.4", trusted, "203.0.113.9"},
		{"trusted peer honors XFF", "10.0.0.5:5000", "1.2.3.4", trusted, "1.2.3.4"},
		{"trusted peer, rightmost-untrusted hop", "10.0.0.5:5000", "1.2.3.4, 10.0.0.9", trusted, "1.2.3.4"},
		{"trusted peer, no XFF falls back to peer", "10.0.0.5:5000", "", trusted, "10.0.0.5"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r, err := http.NewRequest(http.MethodGet, "/", nil)
			if err != nil {
				t.Fatalf("http.NewRequest: %v", err)
			}
			r.RemoteAddr = tt.remoteAddr
			if tt.xff != "" {
				r.Header.Set("X-Forwarded-For", tt.xff)
			}
			if got := clientIP(r, tt.trusted); got != tt.want {
				t.Errorf("clientIP = %q, want %q", got, tt.want)
			}
		})
	}
}

// A spoofed, rotating X-Forwarded-For from an untrusted peer must NOT bypass
// the limiter: all requests key on the real RemoteAddr, so the bucket trips.
func TestRateLimit_SpoofedXFFCannotBypass(t *testing.T) {
	rl := NewRateLimiter(60, nil) // no trusted proxies
	allowed := 0
	for i := 0; i < 200; i++ {
		r, err := http.NewRequest(http.MethodGet, "/", nil)
		if err != nil {
			t.Fatalf("http.NewRequest: %v", err)
		}
		r.RemoteAddr = "203.0.113.9:5000"
		r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i%256))
		if rl.Allow(clientIP(r, rl.trusted)) {
			allowed++
		}
	}
	if allowed > 60 {
		t.Errorf("spoofed XFF bypassed limiter: %d/200 allowed (burst is 60)", allowed)
	}
}

func TestRateLimit_VisitorMapBounded(t *testing.T) {
	rl := NewRateLimiter(60, nil)
	for i := 0; i < maxVisitors+500; i++ {
		rl.Allow(fmt.Sprintf("10.1.%d.%d", i/256, i%256))
	}
	rl.mu.Lock()
	n := len(rl.visitors)
	rl.mu.Unlock()
	if n > maxVisitors {
		t.Errorf("visitor map exceeded cap: %d > %d", n, maxVisitors)
	}
}

// --- Security: export redaction allowlist ---

func TestRedactByProvider(t *testing.T) {
	tests := []struct {
		name     string
		typ      string
		in       map[string]string
		redacted []string // keys expected to be ***REDACTED***
		kept     []string // keys expected to survive verbatim
	}{
		{"discord url is secret", "discord", map[string]string{"url": "https://discord.com/api/webhooks/1/abc"}, []string{"url"}, nil},
		{"opsgenie api_key redacted, priority kept", "opsgenie", map[string]string{"api_key": "k", "priority": "P1", "eu": "true"}, []string{"api_key"}, []string{"priority", "eu"}},
		{"email creds redacted, routing kept", "email", map[string]string{"host": "smtp.x.com", "port": "587", "to": "a@x.com", "from": "b@x.com", "user": "u", "pass": "p"}, []string{"user", "pass"}, []string{"host", "port", "to", "from"}},
		{"telegram token redacted, chat_id kept", "telegram", map[string]string{"token": "123:ABC", "chat_id": "42"}, []string{"token"}, []string{"chat_id"}},
		{"unknown provider redacts everything", "mystery", map[string]string{"anything": "x"}, []string{"anything"}, nil},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			out := models.RedactAlertSettings(tt.typ, tt.in)
			for _, k := range tt.redacted {
				if out[k] != "***REDACTED***" {
					t.Errorf("key %q: expected redacted, got %q", k, out[k])
				}
			}
			for _, k := range tt.kept {
				if out[k] != tt.in[k] {
					t.Errorf("key %q: expected kept %q, got %q", k, tt.in[k], out[k])
				}
			}
		})
	}
}
