package web

import (
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

func newTestServer(t *testing.T) (*Server, *store.Store) {
	t.Helper()
	st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatalf("store.New: %v", err)
	}
	t.Cleanup(func() { st.Close() })
	// Empty passwordHash → RequireAuth/CSRF are bypassed; we call handlers directly anyway.
	s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
	return s, st
}

// A host-report body carrying vitals, cloudflared, and one rich storage target (with SMART).
const testReportJSON = `{
	"host": {"cpu_percent": 12.5, "memory_percent": 40.0, "disk_percent": 24.0},
	"cloudflared": {"status": "healthy"},
	"storage_targets": [
		{"name": "felhom-usb", "type": "usb", "role": "user-data", "state": "active",
		 "mount_path": "/mnt/felhom-drives/felhom-usb", "reachable": true, "used_fraction": 0.73,
		 "smart": {"health": "PASSED", "temperature_c": 41, "percentage_used": 3}},
		{"name": "local-lvm", "type": "lvmthin", "role": "docker-data", "state": "active",
		 "used_fraction": 0.31, "thin_pool": {"data_used_fraction": 0.31},
		 "smart": {"health": "PASSED"}}
	]
}`

// TestHostStatus exercises the staleness-band mapping deterministically (the badge must agree
// with the HostStalenessChecker: stale after threshold, down after 2×).
func TestHostStatus(t *testing.T) {
	s, _ := newTestServer(t) // staleThreshold = 30m
	now := time.Now()
	cases := []struct {
		name string
		last *time.Time
		want string
	}{
		{"never reported", nil, "pending"},
		{"fresh", tptr(now.Add(-1 * time.Minute)), "ok"},
		{"stale", tptr(now.Add(-45 * time.Minute)), "stale"},
		{"down", tptr(now.Add(-3 * time.Hour)), "down"},
	}
	for _, c := range cases {
		if got := s.hostStatus(c.last); got != c.want {
			t.Errorf("%s: hostStatus = %q, want %q", c.name, got, c.want)
		}
	}
}

func tptr(t time.Time) *time.Time { return &t }

func TestHandleHostsList(t *testing.T) {
	s, st := newTestServer(t)

	// Host A: enrolled + reported (→ ONLINE, has vitals + storage).
	if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: "k1"}); err != nil {
		t.Fatal(err)
	}
	if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("demo-felhom-01", 9201),
		CustomerID: "c1", HostID: "demo-felhom-01", VMID: 9201, DisplayName: "acme", Status: "running",
		ControllerVersion: "0.87.0"}); err != nil {
		t.Fatal(err)
	}
	if err := st.SaveHostReport("demo-felhom-01", "c1", []byte(testReportJSON), store.HostReportDenorm{
		AgentVersion: "0.43.0", CPUPercent: 12.5, MemoryPercent: 40, DiskPercent: 24,
		GuestTotal: 1, GuestRunning: 1, CloudflaredStatus: "healthy"}); err != nil {
		t.Fatal(err)
	}
	// Host B: enrolled, never reported (→ NO REPORT).
	if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-02", CustomerID: "c2", APIKey: "k2"}); err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	s.handleHostsList(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil))
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	body := rr.Body.String()

	// Two host rows.
	if n := strings.Count(body, `window.location='/hosts/`); n != 2 {
		t.Errorf("want 2 host rows, got %d", n)
	}
	// Statuses.
	if !strings.Contains(body, "ONLINE") {
		t.Error("reported host should show ONLINE")
	}
	if !strings.Contains(body, "NO REPORT") {
		t.Error("never-reported host should show NO REPORT")
	}
	// Worst storage fill = 73% (felhom-usb), not the 31% lvmthin.
	if !strings.Contains(body, "73%") {
		t.Error("worst storage fill (73%) not rendered")
	}
	// No action buttons on a read-only page.
	if strings.Contains(strings.ToLower(body), "<button") {
		t.Error("hosts list must not contain action buttons")
	}
}

func TestHandleHostDetail(t *testing.T) {
	s, st := newTestServer(t)

	const secretKey = "SUPER-SECRET-HOST-KEY-42"
	if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: secretKey}); err != nil {
		t.Fatal(err)
	}
	if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("demo-felhom-01", 9201),
		CustomerID: "c1", HostID: "demo-felhom-01", VMID: 9201, DisplayName: "acme", Status: "running",
		ControllerVersion: "0.87.0"}); err != nil {
		t.Fatal(err)
	}
	if err := st.SaveHostReport("demo-felhom-01", "c1", []byte(testReportJSON), store.HostReportDenorm{
		AgentVersion: "0.43.0", CloudflaredStatus: "healthy"}); err != nil {
		t.Fatal(err)
	}
	// DR + escrow present (escrow row must exist before the DR bundle UPDATE).
	if _, _, err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z", ""); err != nil {
		t.Fatal(err)
	}
	if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity"), `{"v":1}`); err != nil {
		t.Fatal(err)
	}

	rr := httptest.NewRecorder()
	s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-01", nil), "demo-felhom-01")
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	body := rr.Body.String()

	for _, want := range []string{
		"demo-felhom-01", // identity
		"9201",           // guest
		"0.87.0",         // controller version
		"felhom-usb",     // storage target
		"PASSED",         // SMART health
		"41°C",           // SMART temperature
		"user-data",      // role
		"/customers/c1",  // customer cross-link
	} {
		if !strings.Contains(body, want) {
			t.Errorf("detail body missing %q", want)
		}
	}
	// DR + escrow presence surfaced (booleans, not blobs).
	if !strings.Contains(body, "DR / Backup") || strings.Count(body, "present") < 2 {
		t.Error("DR/escrow presence not both shown")
	}
	// SECURITY: the host api_key must never be rendered.
	if strings.Contains(body, secretKey) {
		t.Errorf("SECRET LEAK: detail page rendered the host api_key")
	}
	// v0.46.0: the ONLY host actions are the two log-bundle request forms (the page is
	// otherwise still read-only — no destructive/host-mutating buttons).
	// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
	// danger-zone card must NOT render for it — this pin now doubles as the
	// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
	if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
		t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
	}
	if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
		t.Error("the request-logs forms are missing — every button must be a log-bundle request")
	}
	// The Diagnostics section renders with its honest latency hint.
	if !strings.Contains(body, "Diagnostics") || !strings.Contains(body, "72 h") {
		t.Error("Diagnostics log-bundle section missing")
	}
}

func TestHandleHostDetail_Unknown(t *testing.T) {
	s, _ := newTestServer(t)
	rr := httptest.NewRecorder()
	s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope", nil), "nope")
	if rr.Code != http.StatusNotFound {
		t.Errorf("unknown host: status = %d, want 404", rr.Code)
	}
}

func TestHandleHostDetail_NoReport(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.UpsertHost(&store.Host{HostID: "fresh-host", CustomerID: "c9", APIKey: "k9"}); err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/fresh-host", nil), "fresh-host")
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	body := rr.Body.String()
	if !strings.Contains(body, "waiting for first report") {
		t.Error("no-report host should render 'waiting for first report'")
	}
}
