package main

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"reasonix/internal/config"
)

func TestHangAgeBucket(t *testing.T) {
	cases := []struct {
		age  time.Duration
		want string
	}{
		{12 * time.Second, "s_10_15"},
		{16 * time.Second, "s_15_30"},
		{45 * time.Second, "s_30_60"},
		{2 * time.Minute, "m_1_5"},
		{10 * time.Minute, "m_5_plus"},
	}
	for _, c := range cases {
		if got := hangAgeBucket(c.age); got != c.want {
			t.Errorf("hangAgeBucket(%s) = %q, want %q", c.age, got, c.want)
		}
	}
}

func TestMainThreadHangReportIsStructuredPerformanceReport(t *testing.T) {
	last := time.Date(2026, 7, 4, 9, 19, 11, 0, time.UTC)
	observed := last.Add(16 * time.Second)

	r := mainThreadHangReport(16*time.Second, last, observed)

	wantLabel, wantErrorType, _, _ := mainThreadDiagnosticIdentity()
	if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != wantLabel {
		t.Fatalf("unexpected report identity: %+v", r)
	}
	if r.SchemaVersion != 2 || r.ErrorType != wantErrorType || r.TopFrame == "" || r.OccurredAt == "" {
		t.Fatalf("structured fields missing: %+v", r)
	}
	for _, want := range []string{"Reasonix detected", "last heartbeat:", "bucket: s_15_30", "goroutines:"} {
		if !strings.Contains(r.Message, want) {
			t.Fatalf("hang report message missing %q:\n%s", want, r.Message)
		}
	}
}

func TestMainThreadHeartbeatAgeIgnoresWallClockJump(t *testing.T) {
	oldBase := mainThreadClockBase
	oldElapsed := mainThreadLastHeartbeatElapsed.Load()
	oldWall := mainThreadLastHeartbeatWall.Load()
	t.Cleanup(func() {
		mainThreadClockBase = oldBase
		mainThreadLastHeartbeatElapsed.Store(oldElapsed)
		mainThreadLastHeartbeatWall.Store(oldWall)
	})

	base := time.Now()
	mainThreadClockBase = base
	mainThreadLastHeartbeatElapsed.Store(int64(time.Second))
	mainThreadLastHeartbeatWall.Store(base.Add(-time.Hour).UnixNano())

	age, _, ok := mainThreadHeartbeatAge(base.Add(2 * time.Second))
	if !ok {
		t.Fatal("expected heartbeat age")
	}
	if age != time.Second {
		t.Fatalf("age = %s, want monotonic elapsed 1s despite wall-clock jump", age)
	}
}

func TestWatchdogResetsHeartbeatAfterSleepGap(t *testing.T) {
	oldBase := mainThreadClockBase
	oldElapsed := mainThreadLastHeartbeatElapsed.Load()
	oldWall := mainThreadLastHeartbeatWall.Load()
	t.Cleanup(func() {
		mainThreadClockBase = oldBase
		mainThreadLastHeartbeatElapsed.Store(oldElapsed)
		mainThreadLastHeartbeatWall.Store(oldWall)
	})

	base := time.Now()
	mainThreadClockBase = base
	recordMainThreadHeartbeat(base)
	wake := base.Add(8 * time.Hour)
	if !resetMainThreadHeartbeatAfterSleep(base, wake) {
		t.Fatal("expected sleep gap to reset the heartbeat")
	}
	age, _, ok := mainThreadHeartbeatAge(wake.Add(mainThreadHangCheckInterval))
	if !ok || age != mainThreadHangCheckInterval {
		t.Fatalf("age after wake = %s, ok=%v; want %s", age, ok, mainThreadHangCheckInterval)
	}
	if resetMainThreadHeartbeatAfterSleep(wake, wake.Add(mainThreadHangCheckInterval)) {
		t.Fatal("ordinary watchdog interval was treated as sleep")
	}
}

func TestRecordMainThreadHangWritesPendingReportAndMetrics(t *testing.T) {
	t.Cleanup(func() {
		removeAllPendingCrashes()
		os.Remove(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
	})
	app := NewApp()
	app.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))

	last := time.Now().Add(-20 * time.Second)
	app.recordMainThreadHang(20*time.Second, last, time.Now())

	r, ok := readPending(t)
	if !ok {
		t.Fatal("expected pending hang report")
	}
	wantLabel, _, _, _ := mainThreadDiagnosticIdentity()
	if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != wantLabel {
		t.Fatalf("pending report = %+v", r)
	}
	c := readCounters(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
	metricBucket := mainThreadMetricBucket()
	if got := c["desktop_hang"][metricBucket]; got != 1 {
		t.Fatalf("desktop_hang/%s = %d, want 1", metricBucket, got)
	}
	if got := c["desktop_hang_age"]["s_15_30"]; got != 1 {
		t.Fatalf("desktop_hang_age/s_15_30 = %d, want 1", got)
	}
}
