package loop

import (
	"context"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/denn-gubsky/loomcycle/internal/config"
	"github.com/denn-gubsky/loomcycle/internal/providers"
	"github.com/denn-gubsky/loomcycle/internal/steer"
	"github.com/denn-gubsky/loomcycle/internal/tools"
)

func cptr[T any](v T) *T { return &v }

func userMsg(text string) providers.Message {
	return providers.Message{Role: "user", Content: []providers.ContentBlock{{Type: "text", Text: text}}}
}
func asstMsg(text string) providers.Message {
	return providers.Message{Role: "assistant", Content: []providers.ContentBlock{{Type: "text", Text: text}}}
}
func toolResultMsg() providers.Message {
	return providers.Message{Role: "user", Content: []providers.ContentBlock{{Type: "tool_result", Text: "r"}}}
}

// CompactionSplit keeps at least keepLastN, snapped to a clean (fresh user-turn)
// boundary so a tool_use/tool_result pair is never split, and pins the first
// user turn when keep_first.
func TestCompactionSplit(t *testing.T) {
	// u0 a1 u2 a3 u4 a5 — fresh user turns at 0,2,4.
	msgs := []providers.Message{userMsg("0"), asstMsg("1"), userMsg("2"), asstMsg("3"), userMsg("4"), asstMsg("5")}
	// keepLastN=2, keepFirst=true: firstIdx=1; target=4 (fresh user turn) → cut=4.
	firstIdx, cut, ok := CompactionSplit(msgs, 2, true)
	if !ok || firstIdx != 1 || cut != 4 {
		t.Fatalf("got firstIdx=%d cut=%d ok=%v, want 1/4/true", firstIdx, cut, ok)
	}
	// keepLastN=0 → keep none (cut=len), summarize all after firstIdx.
	if _, cut, ok := CompactionSplit(msgs, 0, false); !ok || cut != 6 {
		t.Errorf("keepLastN=0: got cut=%d ok=%v, want 6/true", cut, ok)
	}
	// Don't split a tool cycle: a tool_result user turn at the target index isn't
	// a clean boundary, so cut snaps back to the previous fresh user turn.
	tcyc := []providers.Message{userMsg("0"), asstMsg("tool_use"), toolResultMsg(), asstMsg("3")}
	// target=4-1=3 (index 3 is assistant, not a fresh user) → snap back to 0... but
	// firstIdx=1 (keepFirst), so no clean boundary in (1,3] → cut=len (keep none).
	if fi, cut, ok := CompactionSplit(tcyc, 1, true); !ok || cut != len(tcyc) || fi != 1 {
		t.Errorf("tool-cycle: got fi=%d cut=%d ok=%v, want 1/%d/true (no clean mid-boundary)", fi, cut, ok, len(tcyc))
	}
	// Too short → no-op.
	if _, _, ok := CompactionSplit([]providers.Message{userMsg("0")}, 4, true); ok {
		t.Error("single-message convo should be a no-op (ok=false)")
	}
}

func TestShouldAutoCompact(t *testing.T) {
	on := &config.Compaction{Enabled: cptr(true), AutoCompactAtPct: cptr(80)}
	// used/window = 850/1000 = 85% >= 80 → fire (iter past the debounce).
	if !shouldAutoCompact(on, 850, 1000, 5, -2) {
		t.Error("85% should fire")
	}
	// below threshold
	if shouldAutoCompact(on, 700, 1000, 5, -2) {
		t.Error("70% should not fire")
	}
	// disabled
	if shouldAutoCompact(&config.Compaction{Enabled: cptr(false)}, 900, 1000, 5, -2) {
		t.Error("disabled should not fire")
	}
	// unknown window
	if shouldAutoCompact(on, 900, 0, 5, -2) {
		t.Error("unknown window (0) should not fire")
	}
	// debounce: just compacted last iteration
	if shouldAutoCompact(on, 900, 1000, 3, 2) {
		t.Error("one-iteration debounce should suppress")
	}
	// nil
	if shouldAutoCompact(nil, 900, 1000, 5, -2) {
		t.Error("nil compaction should not fire")
	}
}

// maybeAutoCompact summarizes the middle inline and keeps the pinned task + the
// last-N tail (the auto/self path computes the summary itself).
func TestMaybeAutoCompact_SummarizesAndKeepsTail(t *testing.T) {
	msgs := []providers.Message{userMsg("the task"), asstMsg("a1"), userMsg("q2"), asstMsg("a2"), userMsg("q3"), asstMsg("a3")}
	opts := RunOptions{
		Provider:   &steerProvider{}, // Call returns text "ok" → summary="ok"
		Model:      "x",
		Compaction: &config.Compaction{KeepLastN: cptr(2), KeepFirst: cptr(true), TargetPercentage: cptr(10)},
	}
	var compacted bool
	out, did := maybeAutoCompact(context.Background(), opts, msgs, 0 /* no window cap */, func(providers.Event) {}, "auto")
	if !did {
		t.Fatal("expected compaction to happen")
	}
	compacted = did
	_ = compacted
	// [user(pinned task + summary), assistant(ack), <last 2 verbatim>]
	if len(out) != 4 {
		t.Fatalf("got %d messages, want 4 (summary pair + 2 kept): %+v", len(out), out)
	}
	if out[0].Role != "user" || !strings.Contains(out[0].Content[0].Text, "the task") {
		t.Errorf("first turn should pin the task verbatim: %q", out[0].Content[0].Text)
	}
	if out[2].Content[0].Text != "q3" || out[3].Content[0].Text != "a3" {
		t.Errorf("last-2 tail not kept verbatim: %+v", out[2:])
	}
}

// CompactionMessages is the single source of truth for the compacted history
// shape (the live loop AND replayTranscript both build it). It must start on a
// user turn and end on an assistant turn so the operator's next input alternates
// cleanly (Anthropic et al. require start-with-user + role alternation).
func TestCompactionMessages_Shape(t *testing.T) {
	tail := []providers.Message{
		{Role: "user", Content: []providers.ContentBlock{{Type: "text", Text: "recent question"}}},
		{Role: "assistant", Content: []providers.ContentBlock{{Type: "text", Text: "recent answer"}}},
	}
	msgs := CompactionMessages("THE TASK", "THE SUMMARY", tail)
	if len(msgs) != 4 {
		t.Fatalf("got %d messages, want 4 (summary pair + 2 kept tail)", len(msgs))
	}
	if msgs[0].Role != "user" {
		t.Errorf("first role = %q, want user (providers require start-with-user)", msgs[0].Role)
	}
	if !strings.Contains(msgs[0].Content[0].Text, "THE SUMMARY") || !strings.Contains(msgs[0].Content[0].Text, "THE TASK") {
		t.Errorf("pinned task + summary not both embedded in the user turn: %q", msgs[0].Content[0].Text)
	}
	if msgs[1].Role != "assistant" {
		t.Errorf("msg[1] role = %q, want assistant", msgs[1].Role)
	}
	if msgs[2].Role != "user" || msgs[2].Content[0].Text != "recent question" {
		t.Errorf("kept tail not appended verbatim: %+v", msgs[2])
	}
}

// A steer.KindCompact control delivered to a PARKED interactive run replaces the
// in-memory conversation with the summary pair and RE-parks (no provider call),
// and the next real operator turn's request carries ONLY the compacted history.
// Fail-before: pre-feature, a compact-kind message was appended as a user turn
// (history kept growing) and triggered a provider call.
func TestRun_Interactive_CompactReplacesHistory(t *testing.T) {
	q := make(chan steer.Message, 4)
	parked := make(chan struct{}, 8)
	compacted := make(chan struct{}, 8)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	prov := &steerProvider{} // inject nil → always end_turn; records every request
	done := make(chan struct{})
	go func() {
		_, _ = Run(ctx, RunOptions{
			Provider:    prov,
			Model:       "x",
			Tools:       []tools.Tool{noopTool{}},
			Dispatcher:  tools.NewDispatcher([]tools.Tool{noopTool{}}),
			Segments:    steerSegs(),
			SteerQueue:  q,
			Interactive: true,
			OnEvent: func(ev providers.Event) {
				switch ev.Type {
				case providers.EventAwaitingInput:
					parked <- struct{}{}
				case providers.EventContextCompaction:
					compacted <- struct{}{}
				}
			},
		})
		close(done)
	}()

	waitOn := func(ch <-chan struct{}, what string) {
		select {
		case <-ch:
		case <-time.After(2 * time.Second):
			t.Fatalf("%s: timed out", what)
		}
	}
	calls := func() int { prov.mu.Lock(); defer prov.mu.Unlock(); return len(prov.requests) }

	waitOn(parked, "initial end_turn park")
	if got := calls(); got != 1 {
		t.Fatalf("calls=%d before compact, want 1", got)
	}

	// Compact while parked → emits context_compaction + re-parks, NO provider call.
	q <- steer.Message{Kind: steer.KindCompact, Text: "SUMMARY-X"}
	waitOn(compacted, "compaction applied")
	if got := calls(); got != 1 {
		t.Errorf("compact triggered a provider call (calls=%d, want 1 — must just re-park)", got)
	}

	// A real operator turn now → exactly one provider call on the compacted history.
	q <- steer.Message{Text: "continue"}
	waitOn(parked, "re-park after the real turn")
	if got := calls(); got != 2 {
		t.Fatalf("calls=%d after the real turn, want 2", got)
	}
	prov.mu.Lock()
	last := append([]providers.Message(nil), prov.requests[len(prov.requests)-1]...)
	prov.mu.Unlock()

	txt := func(m providers.Message) string {
		var b strings.Builder
		for _, c := range m.Content {
			b.WriteString(c.Text)
		}
		return b.String()
	}
	if len(last) != 3 {
		t.Fatalf("compacted request has %d messages, want 3 (summary pair + the new turn): %+v", len(last), last)
	}
	if last[0].Role != "user" || !strings.Contains(txt(last[0]), "SUMMARY-X") {
		t.Errorf("msg[0] = %q (%s); want the summary user turn", txt(last[0]), last[0].Role)
	}
	if last[1].Role != "assistant" {
		t.Errorf("msg[1] role = %q, want assistant", last[1].Role)
	}
	if last[2].Role != "user" || txt(last[2]) != "continue" {
		t.Errorf("msg[2] = %q (%s); want user 'continue'", txt(last[2]), last[2].Role)
	}
	for _, m := range last {
		if txt(m) == "go" {
			t.Errorf("the original pre-compaction turn survived: %+v", m)
		}
	}

	cancel()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("run did not terminate after cancel")
	}
}

// ctxUsageProvider records the context footprint visible on ctx at each Call —
// exactly what a Context op=self invoked that turn would report (the loop hands
// the provider the same iterCtx the tools get). The FIRST call reports a large
// InputTokens usage so lastCtxTokens becomes large; every call ends the turn.
type ctxUsageProvider struct {
	mu       sync.Mutex
	seenUsed []int
	firstIn  int
	maxCtx   int
	turn     int
}

func (p *ctxUsageProvider) ID() string                                   { return "ctxusage-test" }
func (p *ctxUsageProvider) Probe(context.Context) error                  { return nil }
func (p *ctxUsageProvider) ListModels(context.Context) ([]string, error) { return nil, nil }
func (p *ctxUsageProvider) Capabilities() providers.Capabilities {
	return providers.Capabilities{Streaming: true, MaxContextTokens: p.maxCtx}
}
func (p *ctxUsageProvider) Call(ctx context.Context, _ providers.Request) (<-chan providers.Event, error) {
	p.mu.Lock()
	p.seenUsed = append(p.seenUsed, tools.ContextUsage(ctx).Used)
	turn := p.turn
	p.turn++
	p.mu.Unlock()

	in := 0
	if turn == 0 {
		in = p.firstIn
	}
	ch := make(chan providers.Event, 2)
	ch <- providers.Event{Type: providers.EventText, Text: "ok"}
	ch <- providers.Event{Type: providers.EventDone, StopReason: "end_turn", Usage: &providers.Usage{InputTokens: in}}
	close(ch)
	return ch, nil
}

// A parked interactive run that compacts (steer.KindCompact) must refresh the
// context footprint so the NEXT operator turn's Context op=self reports the
// compacted (small) size — not the stale pre-compaction footprint.
//
// Fail-before: the loop never refreshed lastCtxTokens on compaction, so op=self
// kept reporting the old ~full context (e.g. 164k / 82%) for a whole turn even
// though the real wire request had already shrunk — the reported bug.
func TestRun_Interactive_ContextUsageRefreshedAfterCompaction(t *testing.T) {
	q := make(chan steer.Message, 4)
	parked := make(chan struct{}, 8)
	compacted := make(chan struct{}, 8)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	prov := &ctxUsageProvider{firstIn: 164000, maxCtx: 200000}
	done := make(chan struct{})
	go func() {
		_, _ = Run(ctx, RunOptions{
			Provider:    prov,
			Model:       "x",
			Tools:       []tools.Tool{noopTool{}},
			Dispatcher:  tools.NewDispatcher([]tools.Tool{noopTool{}}),
			Segments:    steerSegs(),
			SteerQueue:  q,
			Interactive: true,
			OnEvent: func(ev providers.Event) {
				switch ev.Type {
				case providers.EventAwaitingInput:
					parked <- struct{}{}
				case providers.EventContextCompaction:
					compacted <- struct{}{}
				}
			},
		})
		close(done)
	}()
	waitOn := func(ch <-chan struct{}, what string) {
		t.Helper()
		select {
		case <-ch:
		case <-time.After(2 * time.Second):
			t.Fatalf("%s: timed out", what)
		}
	}

	// Turn 0: a real turn that reports a large footprint (164k), then parks.
	waitOn(parked, "initial end_turn park")
	// Compact while parked → shrinks the in-memory history; re-parks (no call).
	q <- steer.Message{Kind: steer.KindCompact, Text: "SUMMARY-X"}
	waitOn(compacted, "compaction applied")
	// A real operator turn now → exactly one provider call on the compacted history.
	q <- steer.Message{Text: "continue"}
	waitOn(parked, "re-park after the real turn")

	prov.mu.Lock()
	seen := append([]int(nil), prov.seenUsed...)
	prov.mu.Unlock()
	if len(seen) < 2 {
		t.Fatalf("want >=2 provider calls, got %d (%v)", len(seen), seen)
	}
	post := seen[len(seen)-1]
	if post >= prov.firstIn {
		t.Fatalf("post-compaction Context op=self footprint = %d; want the small compacted size, not the stale pre-compaction %d", post, prov.firstIn)
	}

	cancel()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("run did not terminate after cancel")
	}
}

// TestCapKeptTailToWindow locks the kept-tail window cap: when the provider
// reports a window, the verbatim tail is reduced (oldest kept turns folded into
// the summarized span, snapping to fresh-user-turn boundaries) until it fits the
// budget — the fix for a compaction that "succeeds" but leaves a tail still over
// the window (the slow-local-model timeout). budget<=0 is a no-op; a single
// over-budget turn is kept rather than dropping to empty.
func TestCapKeptTailToWindow(t *testing.T) {
	big := userMsg(strings.Repeat("x", 800)) // ~200 est tokens (chars/4)
	// [u0, a0, BIG-u1, a1, u2, a2] — fresh user turns at indices 0, 2, 4.
	msgs := []providers.Message{
		userMsg("task"), asstMsg("a0"),
		big, asstMsg("a1"),
		userMsg("q2"), asstMsg("a2"),
	}

	// Initial cut=2 keeps [BIG-u1, a1, u2, a2] (~201 est). Budget 50 forces a
	// drop to the next fresh-user-turn (index 4) → keeps [u2, a2] (~1 est).
	if got := capKeptTailToWindow(msgs, 2, 50); got != 4 {
		t.Errorf("over-budget tail: cut = %d, want 4 (advanced past the big turn)", got)
	}
	// Under budget → unchanged.
	if got := capKeptTailToWindow(msgs, 4, 50); got != 4 {
		t.Errorf("under-budget tail: cut = %d, want 4 (unchanged)", got)
	}
	// budget<=0 (unknown window) → no cap.
	if got := capKeptTailToWindow(msgs, 2, 0); got != 2 {
		t.Errorf("no window: cut = %d, want 2 (no cap)", got)
	}
	// Irreducible: a single over-budget last turn is kept, not dropped to empty.
	huge := []providers.Message{userMsg("task"), big}
	if got := capKeptTailToWindow(huge, 1, 50); got != 1 {
		t.Errorf("irreducible tail: cut = %d, want 1 (kept, not emptied)", got)
	}
}

// ─── RFC BL P3: banking the discarded span ───────────────────────────────────

// TestMaybeAutoCompact_BanksDiscardedSpan: with the callback wired, a compaction
// hands it EXACTLY the turns it is about to drop, and reports the outcome on the
// marker so a pass is auditable from the transcript rather than from a log.
//
// The span identity assertion is the load-bearing one: banking the kept tail
// would duplicate what is still in context, and banking the whole history would
// re-bank the pinned task on every compaction.
func TestMaybeAutoCompact_BanksDiscardedSpan(t *testing.T) {
	msgs := []providers.Message{userMsg("the task"), asstMsg("a1"), userMsg("q2"), asstMsg("a2"), userMsg("q3"), asstMsg("a3")}
	var got []providers.Message
	opts := RunOptions{
		Provider:   &steerProvider{},
		Model:      "x",
		Compaction: &config.Compaction{KeepLastN: cptr(2), KeepFirst: cptr(true), TargetPercentage: cptr(10)},
		BankCompactedSpan: func(_ context.Context, dropped []providers.Message) (string, error) {
			got = dropped
			return "mp_banked", nil
		},
	}
	var info *providers.ContextCompactionEventInfo
	_, did := maybeAutoCompact(context.Background(), opts, msgs, 0, func(ev providers.Event) {
		if ev.Type == providers.EventContextCompaction {
			info = ev.ContextCompaction
		}
	}, "auto")
	if !did {
		t.Fatal("expected compaction to happen")
	}

	// Dropped = everything between the pinned first turn and the kept tail.
	want := []string{"a1", "q2", "a2"}
	if len(got) != len(want) {
		t.Fatalf("banked %d messages, want %d — the span must be exactly what was discarded, not the kept tail or the whole history: %+v", len(got), len(want), got)
	}
	for i, w := range want {
		if got[i].Content[0].Text != w {
			t.Errorf("banked[%d] = %q, want %q", i, got[i].Content[0].Text, w)
		}
	}

	if info == nil || info.MemoryBanked == nil {
		t.Fatalf("the compaction marker carries no memory_banked block: %+v", info)
	}
	if info.MemoryBanked.PendingID != "mp_banked" || info.MemoryBanked.Messages != 3 {
		t.Errorf("marker = %+v, want pending_id mp_banked / messages 3", info.MemoryBanked)
	}
	if info.MemoryBanked.Error != "" {
		t.Errorf("marker carries an error on the success path: %q", info.MemoryBanked.Error)
	}
}

// TestMaybeAutoCompact_NoBankWithoutTheCallback: the default is byte-identical.
// A nil callback is how "the agent did not opt in", "it has no writable memory
// scope", and "there is no store" all arrive, so the marker must carry no
// memory_banked block at all rather than an empty one — an absent field is what
// tells a consumer the feature is off.
func TestMaybeAutoCompact_NoBankWithoutTheCallback(t *testing.T) {
	msgs := []providers.Message{userMsg("the task"), asstMsg("a1"), userMsg("q2"), asstMsg("a2"), userMsg("q3"), asstMsg("a3")}
	opts := RunOptions{
		Provider:   &steerProvider{},
		Model:      "x",
		Compaction: &config.Compaction{KeepLastN: cptr(2), KeepFirst: cptr(true), TargetPercentage: cptr(10)},
	}
	var info *providers.ContextCompactionEventInfo
	_, did := maybeAutoCompact(context.Background(), opts, msgs, 0, func(ev providers.Event) {
		if ev.Type == providers.EventContextCompaction {
			info = ev.ContextCompaction
		}
	}, "auto")
	if !did || info == nil {
		t.Fatal("expected compaction + a marker")
	}
	if info.MemoryBanked != nil {
		t.Errorf("memory_banked present with no callback wired: %+v", info.MemoryBanked)
	}
}

// TestMaybeAutoCompact_SurvivesBankFailure is the invariant that outranks the
// feature: compaction exists to keep a run alive, so a failed queue write must
// name itself on the marker and change nothing else. A compaction that refused to
// complete because banking failed would trade a live run for a memory nicety.
func TestMaybeAutoCompact_SurvivesBankFailure(t *testing.T) {
	msgs := []providers.Message{userMsg("the task"), asstMsg("a1"), userMsg("q2"), asstMsg("a2"), userMsg("q3"), asstMsg("a3")}
	opts := RunOptions{
		Provider:   &steerProvider{},
		Model:      "x",
		Compaction: &config.Compaction{KeepLastN: cptr(2), KeepFirst: cptr(true), TargetPercentage: cptr(10)},
		BankCompactedSpan: func(_ context.Context, _ []providers.Message) (string, error) {
			return "", errors.New("enqueue: database is locked")
		},
	}
	var info *providers.ContextCompactionEventInfo
	out, did := maybeAutoCompact(context.Background(), opts, msgs, 0, func(ev providers.Event) {
		if ev.Type == providers.EventContextCompaction {
			info = ev.ContextCompaction
		}
	}, "auto")
	if !did {
		t.Fatal("a failed bank aborted the compaction — the run keeps its full context and will hit the window")
	}
	// The compacted history is exactly what it would be without banking at all.
	if len(out) != 4 || out[2].Content[0].Text != "q3" {
		t.Errorf("a failed bank changed the compacted history: %+v", out)
	}
	if info == nil || info.MemoryBanked == nil || info.MemoryBanked.Error == "" {
		t.Fatalf("the failure is not named on the marker: %+v", info)
	}
	if info.MemoryBanked.PendingID != "" {
		t.Errorf("a failed bank reported a pending id: %+v", info.MemoryBanked)
	}
}

// TestMaybeAutoCompact_ToolOnlySpanReportsNothingToBank: a span with no dialogue
// (pure tool traffic — the converter drops it) must be distinguishable from the
// feature being off, or an operator debugging "why is nothing being banked" cannot
// tell a filtered span from an unset flag.
func TestMaybeAutoCompact_ToolOnlySpanReportsNothingToBank(t *testing.T) {
	msgs := []providers.Message{userMsg("the task"), asstMsg("a1"), userMsg("q2"), asstMsg("a2"), userMsg("q3"), asstMsg("a3")}
	opts := RunOptions{
		Provider:   &steerProvider{},
		Model:      "x",
		Compaction: &config.Compaction{KeepLastN: cptr(2), KeepFirst: cptr(true), TargetPercentage: cptr(10)},
		BankCompactedSpan: func(_ context.Context, _ []providers.Message) (string, error) {
			return "", nil // BankSpan's nothing-to-bank: no id, no error
		},
	}
	var info *providers.ContextCompactionEventInfo
	_, did := maybeAutoCompact(context.Background(), opts, msgs, 0, func(ev providers.Event) {
		if ev.Type == providers.EventContextCompaction {
			info = ev.ContextCompaction
		}
	}, "auto")
	if !did || info == nil || info.MemoryBanked == nil {
		t.Fatalf("expected a marker with a memory_banked block: %+v", info)
	}
	if info.MemoryBanked.PendingID != "" || info.MemoryBanked.Error == "" {
		t.Errorf("marker = %+v, want no pending id and a named reason", info.MemoryBanked)
	}
}
