package api

import (
	"database/sql"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
	_ "modernc.org/sqlite"
)

const globalKey = "GLOBALKEY"

func newTestHandler(t *testing.T) (*Handler, *store.Store, string) {
	t.Helper()
	path := filepath.Join(t.TempDir(), "test.db")
	st, err := store.New(path, log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatalf("store.New: %v", err)
	}
	t.Cleanup(func() { st.Close() })
	h := New(st, globalKey, "", "", nil, log.New(io.Discard, "", 0))
	return h, st, path
}

func do(h *Handler, method, path, bearer, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, "/api/v1"+path, strings.NewReader(body))
	if bearer != "" {
		req.Header.Set("Authorization", "Bearer "+bearer)
	}
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)
	return rr
}

func TestCheckAuthHost(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})

	mk := func(bearer string) *http.Request {
		req := httptest.NewRequest(http.MethodPost, "/api/v1/host-report", nil)
		if bearer != "" {
			req.Header.Set("Authorization", "Bearer "+bearer)
		}
		return req
	}

	if _, _, isGlobal, ok := h.checkAuthHost(mk(globalKey)); !ok || !isGlobal {
		t.Error("global key should resolve isGlobal=true")
	}
	hostID, custID, isGlobal, ok := h.checkAuthHost(mk("HKEY"))
	if !ok || isGlobal || hostID != "h1" || custID != "c1" {
		t.Errorf("per-host key = %q/%q global=%v ok=%v", hostID, custID, isGlobal, ok)
	}
	if _, _, _, ok := h.checkAuthHost(mk("bogus")); ok {
		t.Error("unknown key should fail")
	}
}

func validReportBody(hostID string) string {
	return `{"host_id":"` + hostID + `","agent_version":"0.3.0",` +
		`"host":{"cpu_percent":3.2,"memory_percent":25,"disk_percent":19,"loadavg":["0.1"],"uptime_seconds":100},` +
		`"guests":[{"vmid":100,"name":"acme","status":"running","controller_version":""},` +
		`{"vmid":101,"name":"beta","status":"stopped"}],` +
		`"storage_targets":[],"backups":[],"cloudflared":{"status":"active"},"audit_tail":[]}`
}

func TestHandleHostReport_ValidAndEnvelopeAndDenorm(t *testing.T) {
	h, st, dbPath := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})

	rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
	if rr.Code != 200 {
		t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
	}
	var env struct {
		Status              string `json:"status"`
		PollIntervalSeconds int    `json:"poll_interval_seconds"`
		Blocked             bool   `json:"blocked"`
		DesiredGeneration   int    `json:"desired_generation"`
		HasSignedOps        bool   `json:"has_signed_ops"`
	}
	json.Unmarshal(rr.Body.Bytes(), &env)
	if env.Status != "ok" || env.PollIntervalSeconds != 900 || env.Blocked || env.DesiredGeneration != 0 || env.HasSignedOps {
		t.Errorf("envelope = %+v", env)
	}

	// Denorm: guest_running counts only "running" (1 of 2). Read via a 2nd connection.
	db, _ := sql.Open("sqlite", dbPath)
	defer db.Close()
	var total, running int
	var cf string
	db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='h1' ORDER BY id DESC LIMIT 1`).
		Scan(&total, &running, &cf)
	if total != 2 || running != 1 || cf != "active" {
		t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf)
	}
	// Guests upserted.
	var gname, gstatus string
	if err := db.QueryRow(`SELECT display_name, status FROM guests WHERE guest_id='h1/100'`).Scan(&gname, &gstatus); err != nil {
		t.Fatalf("guest h1/100 not upserted: %v", err)
	}
	if gname != "acme" || gstatus != "running" {
		t.Errorf("guest = %q/%q", gname, gstatus)
	}
}

func TestHandleHostReport_HostIDMismatch(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("other-host"))
	if rr.Code != http.StatusForbidden {
		t.Errorf("status = %d, want 403", rr.Code)
	}
}

func TestHandleHostReport_UnknownHostUnderGlobalKey(t *testing.T) {
	h, _, _ := newTestHandler(t)
	rr := do(h, http.MethodPost, "/host-report", globalKey, validReportBody("ghost"))
	if rr.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400 (unknown host_id)", rr.Code)
	}
}

func TestHandleHostReport_BlockedCustomer(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
	st.SetCustomerConfigStatus("c1", "blocked")
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1"))
	if rr.Code != 200 {
		t.Fatalf("status = %d", rr.Code)
	}
	var env struct {
		Blocked bool `json:"blocked"`
	}
	json.Unmarshal(rr.Body.Bytes(), &env)
	if !env.Blocked {
		t.Error("blocked customer should yield blocked:true")
	}
}

func TestHandleHostReport_OversizeRejected(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	big := `{"host_id":"h1","guests":[{"vmid":1,"name":"` + strings.Repeat("a", 5<<20) + `"}]}`
	rr := do(h, http.MethodPost, "/host-report", "HKEY", big)
	if rr.Code != http.StatusRequestEntityTooLarge {
		t.Errorf("oversize body status = %d, want 413", rr.Code)
	}
}

func TestAdminCreateHost(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})

	// non-global key (per-customer) → 403
	if rr := do(h, http.MethodPost, "/admin/hosts", "ckey", `{"customer_id":"c1"}`); rr.Code != http.StatusForbidden {
		t.Errorf("per-customer key status = %d, want 403", rr.Code)
	}
	// missing/unknown customer → 400
	if rr := do(h, http.MethodPost, "/admin/hosts", globalKey, `{"customer_id":"nope"}`); rr.Code != http.StatusBadRequest {
		t.Errorf("unknown customer status = %d, want 400", rr.Code)
	}
	// success → 201 + usable key (round-trip)
	rr := do(h, http.MethodPost, "/admin/hosts", globalKey, `{"customer_id":"c1"}`)
	if rr.Code != http.StatusCreated {
		t.Fatalf("mint status = %d, body=%s", rr.Code, rr.Body.String())
	}
	var minted struct {
		HostID string `json:"host_id"`
		APIKey string `json:"api_key"`
	}
	json.Unmarshal(rr.Body.Bytes(), &minted)
	if minted.HostID == "" || minted.APIKey == "" {
		t.Fatalf("mint response = %+v", minted)
	}
	// the minted key authenticates a host-report
	rr2 := do(h, http.MethodPost, "/host-report", minted.APIKey, validReportBody(minted.HostID))
	if rr2.Code != 200 {
		t.Errorf("round-trip host-report with minted key = %d, body=%s", rr2.Code, rr2.Body.String())
	}
}

// TestHostReport_GoldenContract drives the real handler with the shared golden
// host-report and proves hostReportPayload still extracts what it needs from the
// real wire shape (denorm + guest upsert).
//
// testdata/host-report.golden.json MUST be kept byte-identical with felhom-agent's
// internal/hub/testdata/host-report.golden.json — it is a duplicated contract until
// a shared types module exists (revisit when slices 5/6 add real fields).
func TestHostReport_GoldenContract(t *testing.T) {
	h, st, dbPath := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
	st.UpsertHost(&store.Host{HostID: "demo-host-01", CustomerID: "c1", APIKey: "HKEY"})

	golden, err := os.ReadFile("testdata/host-report.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	rr := do(h, http.MethodPost, "/host-report", "HKEY", string(golden))
	if rr.Code != 200 {
		t.Fatalf("golden report status = %d, body=%s", rr.Code, rr.Body.String())
	}

	db, _ := sql.Open("sqlite", dbPath)
	defer db.Close()

	var total, running int
	var cf string
	if err := db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
		Scan(&total, &running, &cf); err != nil {
		t.Fatal(err)
	}
	if total != 2 || running != 1 || cf != "active" {
		t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf)
	}

	var guestCount int
	db.QueryRow(`SELECT COUNT(*) FROM guests WHERE host_id='demo-host-01'`).Scan(&guestCount)
	if guestCount != 2 {
		t.Errorf("guests upserted = %d, want 2", guestCount)
	}

	// storage_targets (slice 5) must be persisted verbatim in report_json.
	var reportJSON string
	if err := db.QueryRow(`SELECT report_json FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
		Scan(&reportJSON); err != nil {
		t.Fatal(err)
	}
	var parsed hostReportPayload
	if err := json.Unmarshal([]byte(reportJSON), &parsed); err != nil {
		t.Fatalf("persisted report_json does not parse: %v", err)
	}
	if len(parsed.StorageTargets) != 2 {
		t.Errorf("persisted storage_targets = %d, want 2", len(parsed.StorageTargets))
	}
	if parsed.StorageTargets[0].Type != "lvmthin" || parsed.StorageTargets[0].ThinPool == nil {
		t.Errorf("storage_targets[0] = %+v, want lvmthin with thin_pool", parsed.StorageTargets[0])
	}
}

// TestHostStorageTarget_GoldenContract asserts the hub's hostStorageTarget mirror covers
// the golden's storage_targets[0] field-for-field (the bidirectional key-set test, the hub
// half of the cross-repo contract). It round-trips the golden element through the mirror
// struct and requires the re-marshaled key set to match exactly — neither missing a field
// the agent sends nor inventing one it doesn't.
func TestHostStorageTarget_GoldenContract(t *testing.T) {
	raw, err := os.ReadFile("testdata/host-report.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden struct {
		StorageTargets []json.RawMessage `json:"storage_targets"`
	}
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatal(err)
	}
	if len(golden.StorageTargets) == 0 {
		t.Fatal("golden has no storage_targets to check")
	}

	var goldenKeys map[string]any
	json.Unmarshal(golden.StorageTargets[0], &goldenKeys)

	var mirror hostStorageTarget
	if err := json.Unmarshal(golden.StorageTargets[0], &mirror); err != nil {
		t.Fatalf("golden storage target does not parse into the mirror: %v", err)
	}
	b, _ := json.Marshal(mirror)
	var mirrorKeys map[string]any
	json.Unmarshal(b, &mirrorKeys)

	assertSameStorageKeys(t, "storage_targets[0]", goldenKeys, mirrorKeys)
	assertSameStorageKeys(t, "storage_targets[0].smart", goldenKeys["smart"], mirrorKeys["smart"])
	assertSameStorageKeys(t, "storage_targets[0].thin_pool", goldenKeys["thin_pool"], mirrorKeys["thin_pool"])
}

func TestHostBackup_GoldenContract(t *testing.T) {
	raw, err := os.ReadFile("testdata/host-report.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden struct {
		Backups []json.RawMessage `json:"backups"`
	}
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatal(err)
	}
	if len(golden.Backups) == 0 {
		t.Fatal("golden has no backups to check")
	}
	var goldenKeys map[string]any
	json.Unmarshal(golden.Backups[0], &goldenKeys)
	var mirror hostBackup
	if err := json.Unmarshal(golden.Backups[0], &mirror); err != nil {
		t.Fatalf("golden backup does not parse into the mirror: %v", err)
	}
	b, _ := json.Marshal(mirror)
	var mirrorKeys map[string]any
	json.Unmarshal(b, &mirrorKeys)
	assertSameStorageKeys(t, "backups[0]", goldenKeys, mirrorKeys)
}

func TestHostRestoreTest_GoldenContract(t *testing.T) {
	raw, err := os.ReadFile("testdata/host-report.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden struct {
		RestoreTests []json.RawMessage `json:"restore_tests"`
	}
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatal(err)
	}
	if len(golden.RestoreTests) == 0 {
		t.Fatal("golden has no restore_tests to check")
	}
	var goldenKeys map[string]any
	json.Unmarshal(golden.RestoreTests[0], &goldenKeys)
	var mirror hostRestoreTest
	if err := json.Unmarshal(golden.RestoreTests[0], &mirror); err != nil {
		t.Fatalf("golden restore-test does not parse into the mirror: %v", err)
	}
	b, _ := json.Marshal(mirror)
	var mirrorKeys map[string]any
	json.Unmarshal(b, &mirrorKeys)
	assertSameStorageKeys(t, "restore_tests[0]", goldenKeys, mirrorKeys)
}

func TestHostPBSSnapshot_GoldenContract(t *testing.T) {
	raw, err := os.ReadFile("testdata/host-report.golden.json")
	if err != nil {
		t.Fatal(err)
	}
	var golden struct {
		PBSSnapshots []json.RawMessage `json:"pbs_snapshots"`
	}
	if err := json.Unmarshal(raw, &golden); err != nil {
		t.Fatal(err)
	}
	if len(golden.PBSSnapshots) == 0 {
		t.Fatal("golden has no pbs_snapshots to check")
	}
	var goldenKeys map[string]any
	json.Unmarshal(golden.PBSSnapshots[0], &goldenKeys)
	var mirror hostPBSSnapshot
	if err := json.Unmarshal(golden.PBSSnapshots[0], &mirror); err != nil {
		t.Fatalf("golden pbs snapshot does not parse into the mirror: %v", err)
	}
	b, _ := json.Marshal(mirror)
	var mirrorKeys map[string]any
	json.Unmarshal(b, &mirrorKeys)
	assertSameStorageKeys(t, "pbs_snapshots[0]", goldenKeys, mirrorKeys)
}

func assertSameStorageKeys(t *testing.T, where string, a, b any) {
	t.Helper()
	ka, kb := sortedKeys(a), sortedKeys(b)
	if !reflect.DeepEqual(ka, kb) {
		t.Errorf("contract drift at %s:\n golden keys = %v\n mirror keys = %v", where, ka, kb)
	}
}

func sortedKeys(v any) []string {
	m, ok := v.(map[string]any)
	if !ok {
		return nil
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}
