package main

import (
	"fmt"
	"testing"
	"time"
)

// TestConvIDFlatCaseInvariant pins the load-bearing prompt-cache invariant of the
// per-(room,thread) refactor: the MAIN-timeline conv id (threadRoot "") must stay
// byte-identical to the pre-threading per-room value, so rooms that existed before
// threading keep their warm Grok prompt-cache routing; and a thread must get a DISTINCT
// id so divergent thread prefixes don't thrash one shared cache slot. Also asserts the
// flag-off path returns "" (no header) for both cases.
func TestConvIDFlatCaseInvariant(t *testing.T) {
	b := &Bot{cfg: &Config{GrokPromptCache: true}}

	flat := b.convID("!room:vojo.chat", "")
	legacy := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat"))
	if flat != legacy {
		t.Fatalf("flat convID = %q, want legacy per-room value %q (prompt-cache continuity)", flat, legacy)
	}

	threaded := b.convID("!room:vojo.chat", "$root")
	if threaded == flat {
		t.Fatalf("threaded convID must differ from the flat/main-timeline id, both = %q", flat)
	}
	if want := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat|$root")); threaded != want {
		t.Fatalf("threaded convID = %q, want %q", threaded, want)
	}

	off := &Bot{cfg: &Config{GrokPromptCache: false}}
	if got := off.convID("!room:vojo.chat", ""); got != "" {
		t.Fatalf("convID with GrokPromptCache off must be empty, got %q", got)
	}
	if got := off.convID("!room:vojo.chat", "$root"); got != "" {
		t.Fatalf("threaded convID with GrokPromptCache off must be empty, got %q", got)
	}
}

// TestSnapshotAppendBufNilPaths exercises the nested-map nil reads the per-(room,thread)
// keying introduced: a never-seen room, a known room but unknown thread, and the round-trip
// of a single append. A nil inner map / nil convBuf must read back as nil history (exactly
// what a fresh "new chat" wants), never panic.
func TestSnapshotAppendBufNilPaths(t *testing.T) {
	b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}

	if got := b.snapshotBuf("!a", ""); got != nil {
		t.Fatalf("snapshot of a never-seen room must be nil, got %v", got)
	}

	b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: "hi", isBot: false})
	got := b.snapshotBuf("!a", "")
	if len(got) != 1 || got[0].body != "hi" {
		t.Fatalf("snapshot after one append = %v, want one message 'hi'", got)
	}

	// Same room, a DIFFERENT thread that was never appended to → nil (inner map exists, key absent).
	if got := b.snapshotBuf("!a", "$other"); got != nil {
		t.Fatalf("snapshot of an unknown thread in a known room must be nil, got %v", got)
	}
	// A different room entirely → nil (outer key absent).
	if got := b.snapshotBuf("!b", ""); got != nil {
		t.Fatalf("snapshot of a different room must be nil, got %v", got)
	}
}

// TestAppendBufTrimsToLimit asserts a single conversation's buffer is bounded to
// MaxCtxEvent*2 (min 8) and keeps the most recent messages (FIFO drop of the oldest).
func TestAppendBufTrimsToLimit(t *testing.T) {
	b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
	const limit = 20 // MaxCtxEvent*2
	for i := 0; i < limit+5; i++ {
		b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)})
	}
	got := b.snapshotBuf("!a", "")
	if len(got) != limit {
		t.Fatalf("buffer length = %d, want capped at %d", len(got), limit)
	}
	// Oldest 5 dropped; the window starts at m5 and ends at m24.
	if got[0].body != "m5" || got[len(got)-1].body != "m24" {
		t.Fatalf("buffer window = [%s..%s], want [m5..m24]", got[0].body, got[len(got)-1].body)
	}
}

// TestAppendBufLRUEviction proves the per-room conversation-buffer cap: once a room exceeds
// maxConvBuffersPerRoom, the LEAST-recently-touched conversation is evicted, and the
// just-touched conversation is never the victim.
func TestAppendBufLRUEviction(t *testing.T) {
	b := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}

	// Touch exactly maxConvBuffersPerRoom+1 distinct threads, oldest-first.
	for i := 0; i <= maxConvBuffersPerRoom; i++ {
		b.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
	}
	if n := len(b.buf["!a"]); n != maxConvBuffersPerRoom {
		t.Fatalf("room buffer count = %d, want capped at %d", n, maxConvBuffersPerRoom)
	}
	if got := b.snapshotBuf("!a", "$t0"); got != nil {
		t.Fatalf("the coldest conversation ($t0) must have been evicted, got %v", got)
	}
	if got := b.snapshotBuf("!a", fmt.Sprintf("$t%d", maxConvBuffersPerRoom)); got == nil {
		t.Fatal("the just-appended conversation must never be the eviction victim")
	}

	// Re-touch protection: fill to the cap, re-touch the OLDEST, then overflow by one.
	// The re-touched conversation must survive; the new second-oldest becomes the victim.
	b2 := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
	for i := 0; i < maxConvBuffersPerRoom; i++ {
		b2.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
	}
	b2.appendBuf("!a", "$t0", bufferedMsg{body: "x"})       // re-touch the oldest → now newest
	b2.appendBuf("!a", "$overflow", bufferedMsg{body: "x"}) // overflow → evict the coldest
	if got := b2.snapshotBuf("!a", "$t0"); got == nil {
		t.Fatal("a re-touched conversation ($t0) must NOT be evicted")
	}
	if got := b2.snapshotBuf("!a", "$t1"); got != nil {
		t.Fatalf("the new coldest conversation ($t1) must be the victim, got %v", got)
	}
}

// TestTypingRefcount covers the per-room typing refcount the per-(room,thread) concurrency
// relies on (Matrix typing is room-scoped, so several thread generations share one
// indicator). Only the LAST release reports "last" (clears the indicator), and a release
// after forgetRoom dropped the key must self-heal without leaving a leaked negative entry.
func TestTypingRefcount(t *testing.T) {
	b := &Bot{typingRefs: make(map[string]int)}

	b.typingAcquire("!a")
	b.typingAcquire("!a")
	if b.typingRefs["!a"] != 2 {
		t.Fatalf("typingRefs after two acquires = %d, want 2", b.typingRefs["!a"])
	}
	if last := b.typingRelease("!a"); last {
		t.Fatal("first release of two must NOT be the last")
	}
	if last := b.typingRelease("!a"); !last {
		t.Fatal("second release of two must be the last")
	}
	if _, ok := b.typingRefs["!a"]; ok {
		t.Fatalf("the key must be deleted once the refcount hits 0, still present = %d", b.typingRefs["!a"])
	}

	// forgetRoom mid-flight: two acquires, then the room is forgotten (key deleted), then the
	// in-flight generations release against a missing key. Each lands at -1 (<=0 → "last") and
	// deletes the entry, so no persistent negative count leaks.
	b.typingAcquire("!b")
	b.typingAcquire("!b")
	b.forgetRoom("!b")
	if last := b.typingRelease("!b"); !last {
		t.Fatal("a release after forgetRoom must report last (counter <= 0)")
	}
	if last := b.typingRelease("!b"); !last {
		t.Fatal("a second stale release must also report last")
	}
	if _, ok := b.typingRefs["!b"]; ok {
		t.Fatalf("no negative entry must leak after forgetRoom + stale releases, value = %d", b.typingRefs["!b"])
	}
}

// TestSeedConversation pins the DM new-conversation seeding contract: a freshly rooted
// thread inherits the tail of the room's most recently active conversation only within
// dmSeedWindow; the "" main-timeline buffer (group-era / pre-threading legacy, may carry
// third-party turns) never donates; the seed is persisted into the new thread's buffer
// with the DONOR's lastAt (a seed is not user activity — failed turns must not
// chain-refresh the window); and at most dmSeedMaxMsgs turns are carried.
func TestSeedConversation(t *testing.T) {
	b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
	room := "!dm:vojo.chat"

	// No buffers at all → nil (plain cold start).
	if got := b.seedConversation(room, "$new0"); got != nil {
		t.Fatalf("seed with no donors must be nil, got %v", got)
	}

	// A fresh donor conversation donates its tail…
	for i := 0; i < dmSeedMaxMsgs+2; i++ {
		b.appendBuf(room, "$donor", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)})
	}
	seeded := b.seedConversation(room, "$new1")
	if len(seeded) != dmSeedMaxMsgs {
		t.Fatalf("seed length = %d, want dmSeedMaxMsgs=%d", len(seeded), dmSeedMaxMsgs)
	}
	if seeded[len(seeded)-1].body != fmt.Sprintf("m%d", dmSeedMaxMsgs+1) {
		t.Fatalf("seed must carry the donor TAIL, last = %q", seeded[len(seeded)-1].body)
	}
	// …and the seed is persisted so the new conversation's later turns keep it.
	if got := b.snapshotBuf(room, "$new1"); len(got) != dmSeedMaxMsgs {
		t.Fatalf("persisted seed snapshot = %d msgs, want %d", len(got), dmSeedMaxMsgs)
	}
	// The seeded buffer inherits the donor's lastAt (not time.Now()).
	if got, want := b.buf[room]["$new1"].lastAt, b.buf[room]["$donor"].lastAt; !got.Equal(want) {
		t.Fatalf("seeded lastAt = %v, want donor's %v", got, want)
	}

	// A stale donor (outside dmSeedWindow) must not donate. Age BOTH buffers — the
	// seeded copy above inherited the donor stamp, but it also must not re-donate later.
	for _, cb := range b.buf[room] {
		cb.lastAt = cb.lastAt.Add(-dmSeedWindow - time.Minute)
	}
	if got := b.seedConversation(room, "$new2"); got != nil {
		t.Fatalf("stale donor must not seed, got %v", got)
	}

	// The "" main-timeline buffer never donates, however fresh.
	b2 := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
	b2.appendBuf(room, "", bufferedMsg{sender: "@third:vojo.chat", body: "group-era turn"})
	if got := b2.seedConversation(room, "$new3"); got != nil {
		t.Fatalf("main-timeline buffer must never donate, got %v", got)
	}
}
