package plugin

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// withTempCache redirects config.CacheDir() at t.TempDir for the duration of a
// test (without it the tests write the real user cache).
// Returns the directory that will hold the cache subtree so callers can assert
// paths inside it.
func withTempCache(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	t.Setenv("REASONIX_CACHE_HOME", dir)
	return dir
}

// readStats reads the on-disk stats file for name directly (not through the
// public API) so tests can assert raw file contents — what we wrote and in
// what order.
func readStats(t *testing.T, name string) StartupStats {
	t.Helper()
	path := statsPath(name)
	if path == "" {
		t.Fatal("statsPath returned empty")
	}
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read stats %s: %v", path, err)
	}
	var s StartupStats
	if err := json.Unmarshal(b, &s); err != nil {
		t.Fatalf("unmarshal stats: %v", err)
	}
	return s
}

func TestRecordStartupAppends(t *testing.T) {
	withTempCache(t)

	durs := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
	for _, d := range durs {
		if err := RecordStartup("foo", d); err != nil {
			t.Fatalf("RecordStartup: %v", err)
		}
	}

	s := readStats(t, "foo")
	if s.Version != statsVersion {
		t.Fatalf("Version = %d, want %d", s.Version, statsVersion)
	}
	if got := len(s.SamplesMs); got != 3 {
		t.Fatalf("samples = %d, want 3", got)
	}
	// File is written oldest→newest (newest at the tail).
	want := []int64{100, 200, 300}
	for i, w := range want {
		if s.SamplesMs[i] != w {
			t.Fatalf("SamplesMs[%d] = %d, want %d (full=%v)", i, s.SamplesMs[i], w, s.SamplesMs)
		}
	}
	if s.LastSeen.IsZero() {
		t.Fatal("LastSeen should be set after a record")
	}
}

func TestRecordStartupCapsAtWindow(t *testing.T) {
	withTempCache(t)

	const total = 25
	for i := range total {
		// Use distinct values so we can verify the *oldest* ones were dropped.
		if err := RecordStartup("bar", time.Duration(i+1)*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup #%d: %v", i, err)
		}
	}

	s := readStats(t, "bar")
	if got := len(s.SamplesMs); got != maxSamples {
		t.Fatalf("samples = %d, want %d", got, maxSamples)
	}
	// First retained sample should be #6 (1..5 dropped); last should be #25.
	wantFirst := int64(total - maxSamples + 1) // 6
	if s.SamplesMs[0] != wantFirst {
		t.Fatalf("SamplesMs[0] = %d, want %d", s.SamplesMs[0], wantFirst)
	}
	if s.SamplesMs[len(s.SamplesMs)-1] != int64(total) {
		t.Fatalf("SamplesMs[last] = %d, want %d", s.SamplesMs[len(s.SamplesMs)-1], total)
	}
}

func TestRecommendBelowBudgetNoDemote(t *testing.T) {
	withTempCache(t)

	budget := 1 * time.Second
	// All samples comfortably under budget*2 == 2s.
	for _, ms := range []int64{500, 800, 1200, 1500, 900} {
		if err := RecordStartup("ok-plugin", time.Duration(ms)*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup: %v", err)
		}
	}

	got := Recommend("ok-plugin", budget, 3)
	if got.Demote {
		t.Fatalf("Demote = true, want false (reason=%q)", got.Reason)
	}
}

func TestRecommendAboveBudgetDemotes(t *testing.T) {
	withTempCache(t)

	budget := 1 * time.Second
	// First sample is fast (would block a naive "ever-slow" check), then 3
	// consecutive samples above budget*2 == 2s. The plan says demote on
	// "last N consecutive over-budget", so this should trip.
	for _, ms := range []int64{500, 2500, 3000, 4000} {
		if err := RecordStartup("slow-plugin", time.Duration(ms)*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup: %v", err)
		}
	}

	got := Recommend("slow-plugin", budget, 3)
	if !got.Demote {
		t.Fatalf("Demote = false, want true (samples=%+v)", readStats(t, "slow-plugin").SamplesMs)
	}
	if got.Reason == "" {
		t.Fatal("Reason should be non-empty when demoting")
	}
	if got.P99 <= 0 {
		t.Fatalf("P99 = %v, want > 0", got.P99)
	}
}

func TestRecommendAtBudgetDemotes(t *testing.T) {
	withTempCache(t)

	budget := 5 * time.Second
	for i := range 3 {
		if err := RecordStartup("timeout-plugin", budget); err != nil {
			t.Fatalf("RecordStartup #%d: %v", i, err)
		}
	}

	got := Recommend("timeout-plugin", budget, 3)
	if !got.Demote {
		t.Fatalf("Demote = false, want true for repeated budget hits (samples=%+v)", readStats(t, "timeout-plugin").SamplesMs)
	}
}

func TestRecommendMissingStatsNoDemote(t *testing.T) {
	withTempCache(t)

	// Never recorded anything → should not demote a brand-new plugin.
	got := Recommend("never-seen", 1*time.Second, 3)
	if got.Demote {
		t.Fatalf("Demote = true on missing stats, want false (reason=%q)", got.Reason)
	}
	if got.P99 != 0 {
		t.Fatalf("P99 = %v on missing stats, want 0", got.P99)
	}
}

func TestRecommendOldFailuresFadeOut(t *testing.T) {
	withTempCache(t)

	budget := 1 * time.Second
	// Early samples were terrible (well above budget*2 == 2s), but the most
	// recent three are quick — rolling window must let the plugin recover.
	for _, ms := range []int64{5000, 6000, 7000, 8000, 500, 600, 700} {
		if err := RecordStartup("recovered", time.Duration(ms)*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup: %v", err)
		}
	}

	got := Recommend("recovered", budget, 3)
	if got.Demote {
		t.Fatalf("Demote = true after recovery, want false (samples=%+v, reason=%q)",
			readStats(t, "recovered").SamplesMs, got.Reason)
	}
}

// TestStatsPathLayout pins the on-disk location so Phase 4's integration code
// can rely on it. We assert the path sits under config.CacheDir()/mcp and that
// the slug strips raw separators — exact slug rule lives in cache.go.
func TestStatsPathLayout(t *testing.T) {
	root := withTempCache(t)

	p := statsPath("server/with spaces")
	if p == "" {
		t.Fatal("statsPath returned empty")
	}
	wantParent := filepath.Join(root, "mcp")
	if got := filepath.Dir(p); got != wantParent {
		t.Fatalf("parent = %q, want %q", got, wantParent)
	}
	base := filepath.Base(p)
	if filepath.Ext(base) != ".json" {
		t.Fatalf("ext = %q, want .json", filepath.Ext(base))
	}
	if !strings.HasSuffix(base, ".stats.json") {
		t.Fatalf("base = %q, want .stats.json suffix", base)
	}
	if containsAny(base, "/ ") {
		t.Fatalf("slug %q contains raw separators", base)
	}
}

func containsAny(s, chars string) bool {
	for _, c := range chars {
		for _, r := range s {
			if r == c {
				return true
			}
		}
	}
	return false
}

// TestStatsSlugCollisionDoesNotCrossDemote: "foo.bar" and "foo-bar" collapse
// to one slug. Each server now gets its own hash-suffixed file, so a slow
// server's samples must not demote the other, and interleaved startups must
// not reset each other's windows (the old shared-file ownership check made
// each writer wipe the other's history, so neither could ever accumulate
// enough consecutive samples to demote).
func TestStatsSlugCollisionDoesNotCrossDemote(t *testing.T) {
	withTempCache(t)

	// Interleave a chronically slow server with a healthy one sharing the slug.
	for range defaultDemoteAfter {
		if err := RecordStartup("foo.bar", 5*time.Second); err != nil {
			t.Fatalf("RecordStartup foo.bar: %v", err)
		}
		if err := RecordStartup("foo-bar", 10*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup foo-bar: %v", err)
		}
	}
	// Both windows must have accumulated independently despite interleaving.
	slow := readStats(t, "foo.bar")
	fast := readStats(t, "foo-bar")
	if len(slow.SamplesMs) != defaultDemoteAfter || slow.Name != "foo.bar" {
		t.Fatalf("foo.bar window = %+v, want %d samples owned by foo.bar", slow, defaultDemoteAfter)
	}
	if len(fast.SamplesMs) != defaultDemoteAfter || fast.Name != "foo-bar" {
		t.Fatalf("foo-bar window = %+v, want %d samples owned by foo-bar", fast, defaultDemoteAfter)
	}
	// The slow server demotes on its own history; the healthy one does not.
	if rec := Recommend("foo.bar", time.Second, 0); !rec.Demote {
		t.Fatalf("foo.bar should demote on its own history: %+v", rec)
	}
	if rec := Recommend("foo-bar", time.Second, 0); rec.Demote {
		t.Fatalf("foo-bar demoted off foo.bar's samples: %+v", rec)
	}
}

// TestStatsLegacyNamedFileMigrated: a pre-hash stats file whose recorded Name
// matches is this server's own history — Recommend keeps working from it and
// the next write carries it into the hashed path.
func TestStatsLegacyNamedFileMigrated(t *testing.T) {
	withTempCache(t)

	legacy := StartupStats{Version: statsVersion, Name: "legacy", LastSeen: time.Now()}
	for range 3 {
		legacy.SamplesMs = append(legacy.SamplesMs, 100)
	}
	if err := writeStatsAtomic(legacyStatsPath("legacy"), legacy); err != nil {
		t.Fatalf("write legacy stats: %v", err)
	}

	if rec := Recommend("legacy", time.Second, 0); rec.P99 == 0 {
		t.Fatalf("legacy named stats not readable: %+v", rec)
	}
	if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
		t.Fatalf("RecordStartup after legacy: %v", err)
	}
	s := readStats(t, "legacy")
	if s.Name != "legacy" || len(s.SamplesMs) != 4 {
		t.Fatalf("legacy migration = %+v, want name kept and 4 samples", s)
	}
}

// TestStatsLegacyNamelessCollisionNotTrusted: a pre-ownership legacy file has
// no Name, so it is shared by every server whose name collapses to the slug
// and its samples cannot be attributed. It must not demote anyone and must
// not seed a new window.
func TestStatsLegacyNamelessCollisionNotTrusted(t *testing.T) {
	withTempCache(t)

	nameless := StartupStats{Version: statsVersion, LastSeen: time.Now()}
	for range defaultDemoteAfter {
		nameless.SamplesMs = append(nameless.SamplesMs, 5000) // over budget
	}
	if err := writeStatsAtomic(legacyStatsPath("foo.bar"), nameless); err != nil {
		t.Fatalf("write nameless legacy stats: %v", err)
	}

	// Neither colliding server may be demoted off unattributable samples.
	if rec := Recommend("foo.bar", time.Second, 0); rec.Demote {
		t.Fatalf("foo.bar demoted off nameless legacy samples: %+v", rec)
	}
	if rec := Recommend("foo-bar", time.Second, 0); rec.Demote {
		t.Fatalf("foo-bar demoted off nameless legacy samples: %+v", rec)
	}
	// A new write starts a fresh window instead of inheriting the samples.
	if err := RecordStartup("foo.bar", 10*time.Millisecond); err != nil {
		t.Fatalf("RecordStartup: %v", err)
	}
	s := readStats(t, "foo.bar")
	if s.Name != "foo.bar" || len(s.SamplesMs) != 1 {
		t.Fatalf("window after nameless legacy = %+v, want fresh single-sample window", s)
	}
}

// TestStatsLegacyFileWithoutNameAdopted: a nameless file at the hashed path is
// unambiguous — the hash pins it to exactly one server name — so its samples
// stay usable and the next write adopts it without dropping history. (Nameless
// files at the shared legacy path are the untrusted case, covered above.)
func TestStatsLegacyFileWithoutNameAdopted(t *testing.T) {
	withTempCache(t)

	for range 3 {
		if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
			t.Fatalf("RecordStartup: %v", err)
		}
	}
	// Simulate a legacy file: strip the Name field.
	path := statsPath("legacy")
	s := readStats(t, "legacy")
	s.Name = ""
	if err := writeStatsAtomic(path, s); err != nil {
		t.Fatalf("write legacy stats: %v", err)
	}

	// Recommend still reads the legacy file (no ownership check possible).
	if rec := Recommend("legacy", time.Second, 0); rec.P99 == 0 {
		t.Fatalf("legacy stats not readable: %+v", rec)
	}
	// The next write adopts the file without dropping history.
	if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
		t.Fatalf("RecordStartup after legacy: %v", err)
	}
	s = readStats(t, "legacy")
	if s.Name != "legacy" || len(s.SamplesMs) != 4 {
		t.Fatalf("legacy adoption = %+v, want name set and 4 samples kept", s)
	}
}

// TestStatsPathBoundedForLongNames: the hash suffix added for collision
// safety must not push a previously-writable long slug past the 255-byte
// filename component limit (slugs of 236-244 bytes could write
// "<slug>.stats.json" before, but "<slug>-<hash>.stats.json" would exceed it).
func TestStatsPathBoundedForLongNames(t *testing.T) {
	withTempCache(t)

	long := strings.Repeat("a", 240)
	p := statsPath(long)
	if p == "" {
		t.Fatal("statsPath returned empty")
	}
	if base := filepath.Base(p); len(base) > 255 {
		t.Fatalf("component = %d bytes, exceeds 255 limit: %q", len(base), base)
	}
	// The bounded path must still be writable and readable end-to-end.
	if err := RecordStartup(long, 100*time.Millisecond); err != nil {
		t.Fatalf("RecordStartup long name: %v", err)
	}
	s := readStats(t, long)
	if s.Name != long || len(s.SamplesMs) != 1 {
		t.Fatalf("long-name stats = %+v, want one owned sample", s)
	}
	// Distinct long names sharing the truncated stem must keep distinct files.
	other := strings.Repeat("a", 240) + "b"
	if statsPath(other) == p {
		t.Fatalf("distinct long names collapsed to one stats path: %q", p)
	}
}
