package monitor

import (
	"fmt"
	"io"
	"log"
	"path/filepath"
	"testing"

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

func newDiskStore(t *testing.T) *store.Store {
	t.Helper()
	st, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { st.Close() })
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p"})
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
	return st
}

// saveDiskReport records a host-report carrying the host root disk_percent (denorm column) + total/used.
func saveDiskReport(t *testing.T, st *store.Store, pct float64) {
	t.Helper()
	body := fmt.Sprintf(`{"host_id":"h1","host":{"disk_total_bytes":100000000000,"disk_used_bytes":%d}}`, int64(pct*1e9))
	if err := st.SaveHostReport("h1", "c1", []byte(body), store.HostReportDenorm{DiskPercent: pct}); err != nil {
		t.Fatal(err)
	}
}

func quietLog() *log.Logger { return log.New(io.Discard, "", 0) }

// TestHostDiskChecker_Bands covers the band contract (§7-A/C, §10): seed ok (no event), ok→warning,
// warning→critical (escalation), steady (no re-emit), recovery clears + re-arms, and the emitted type is
// host_disk_* (distinct from the guest disk_*).
func TestHostDiskChecker_Bands(t *testing.T) {
	st := newDiskStore(t)
	saveDiskReport(t, st, 80) // ok at init

	var events []string
	onEvent := func(_, eventType, _, _, _, _ string) { events = append(events, eventType) }
	dc := NewHostDiskChecker(st, 90, 95, onEvent, quietLog())

	if dc.GetState("h1") != "ok" {
		t.Fatalf("seed state = %s, want ok", dc.GetState("h1"))
	}
	if len(events) != 0 {
		t.Fatalf("seed must not emit, got %v", events)
	}

	// 80 → nothing.
	saveDiskReport(t, st, 80)
	dc.Check()
	if len(events) != 0 {
		t.Fatalf("80%% must not alert, got %v", events)
	}

	// ok → 91 → warning (one event).
	saveDiskReport(t, st, 91)
	dc.Check()
	if dc.GetState("h1") != bandWarning {
		t.Fatalf("state = %s, want warning", dc.GetState("h1"))
	}
	if len(events) != 1 || events[0] != "host_disk_warning" {
		t.Fatalf("want one host_disk_warning, got %v", events)
	}

	// steady warning: no re-emit.
	saveDiskReport(t, st, 92)
	dc.Check()
	if len(events) != 1 {
		t.Fatalf("steady warning must not re-emit, got %v", events)
	}

	// warning → 96 → critical (escalation).
	saveDiskReport(t, st, 96)
	dc.Check()
	if dc.GetState("h1") != bandCritical {
		t.Fatalf("state = %s, want critical", dc.GetState("h1"))
	}
	if len(events) != 2 || events[1] != "host_disk_critical" {
		t.Fatalf("want host_disk_critical escalation, got %v", events)
	}

	// recovery: 80 clears + re-arms (no recovery event, state back to ok).
	saveDiskReport(t, st, 80)
	dc.Check()
	if dc.GetState("h1") != bandOK {
		t.Fatalf("state = %s, want ok after recovery", dc.GetState("h1"))
	}
	if len(events) != 2 {
		t.Fatalf("recovery must not emit an event, got %v", events)
	}

	// re-armed: a fresh 91 breach alerts again.
	saveDiskReport(t, st, 91)
	dc.Check()
	if len(events) != 3 || events[2] != "host_disk_warning" {
		t.Fatalf("re-arm: a new breach must alert again, got %v", events)
	}
}

// TestHostDiskChecker_Severity asserts the band→severity mapping the dispatcher requires (it only routes
// warning/error): warning band → "warning", critical band → "error" (NOT "critical", which would be dropped).
func TestHostDiskChecker_Severity(t *testing.T) {
	st := newDiskStore(t)
	saveDiskReport(t, st, 50)
	var sev []string
	onEvent := func(_, _, severity, _, _, _ string) { sev = append(sev, severity) }
	dc := NewHostDiskChecker(st, 90, 95, onEvent, quietLog())

	saveDiskReport(t, st, 92)
	dc.Check()
	saveDiskReport(t, st, 97)
	dc.Check()
	if len(sev) != 2 || sev[0] != "warning" || sev[1] != "critical" {
		t.Fatalf("severities = %v, want [warning critical] (hub v0.24.0 routes the natural critical severity)", sev)
	}
}

// TestHostDiskChecker_BornPersistent is the F2 lesson (§7-B): a disk ALREADY over the critical threshold
// when the checker (re)starts must alert on the FIRST Check — there is no crossing to observe.
func TestHostDiskChecker_BornPersistent(t *testing.T) {
	st := newDiskStore(t)
	saveDiskReport(t, st, 97) // already critical at init

	var events []string
	onEvent := func(_, eventType, _, _, _, _ string) { events = append(events, eventType) }
	dc := NewHostDiskChecker(st, 90, 95, onEvent, quietLog())

	// Seed must have left the breached host UNSEEDED (so the first Check emits).
	if dc.GetState("h1") != "unknown" {
		t.Fatalf("an already-breached host must be left unseeded at init, state = %s", dc.GetState("h1"))
	}
	if len(events) != 0 {
		t.Fatalf("init must not emit directly, got %v", events)
	}

	dc.Check()
	if len(events) != 1 || events[0] != "host_disk_critical" {
		t.Fatalf("born-persistent: first Check MUST emit host_disk_critical, got %v", events)
	}

	// COMPANION RED-PROOF: a transition-only design seeds ALL hosts at init (including breached ones), so
	// the breached host's state == current band at first Check → no transition → SILENT forever. We model
	// that by pre-seeding the state to critical (as a seed-all impl would) on a FRESH checker, then Check.
	st2 := newDiskStore(t)
	saveDiskReport(t, st2, 97)
	var silent []string
	dc2 := NewHostDiskChecker(st2, 90, 95, func(_, et, _, _, _, _ string) { silent = append(silent, et) }, quietLog())
	dc2.mu.Lock()
	dc2.states["h1"] = bandCritical // the WRONG (seed-all) design's seed
	dc2.mu.Unlock()
	dc2.Check()
	if len(silent) != 0 {
		t.Fatalf("control: a seed-all (transition-only) impl should be silent on the born-breach, got %v", silent)
	}
	// → The real checker (which leaves breached hosts unseeded) emitted; the transition-only model stayed
	//   silent. That gap is the bug this design fixes.
}

// TestHostDiskChecker_ThresholdDefaults: an unset/invalid threshold config falls back to 90/95, and a
// misordered (crit ≤ warn) config does too — a typo can never silence or invert the alert.
func TestHostDiskChecker_ThresholdDefaults(t *testing.T) {
	cases := []struct{ warn, crit, wantWarn, wantCrit float64 }{
		{0, 0, 90, 95},
		{85, 0, 85, 95},
		{0, 98, 90, 98},
		{95, 90, 90, 95},  // misordered → defaults
		{-5, 200, 90, 95}, // out of range → defaults
	}
	for _, c := range cases {
		w, cr := normalizeDiskThresholds(c.warn, c.crit)
		if w != c.wantWarn || cr != c.wantCrit {
			t.Errorf("normalizeDiskThresholds(%v,%v) = (%v,%v), want (%v,%v)", c.warn, c.crit, w, cr, c.wantWarn, c.wantCrit)
		}
	}
}
