package capture

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"
)

func TestNilSummarizerWithoutAModel(t *testing.T) {
	if s := NewSummarizer(nil, 0, 0, nil); s != nil {
		t.Fatal("a summarizer with no model is not nil")
	}
	var s *Summarizer
	if _, _, err := s.Summarize(context.Background(), "x", "текст"); !errors.Is(err, ErrDisabled) {
		t.Fatalf("got %v, want ErrDisabled", err)
	}
}

// The common case: a short meeting fits in one prompt, so there is exactly one
// model call and no reduce step.
func TestShortTranscriptSkipsTheReduceStep(t *testing.T) {
	f := &fakeCompleter{replies: []string{"— договорились о смете"}}
	s := NewSummarizer(f, 0, 0, nil)
	out, chunks, err := s.Summarize(context.Background(), "смета", "Обсудили смету. Решили подписать.")
	if err != nil {
		t.Fatal(err)
	}
	if chunks != 1 {
		t.Errorf("chunks = %d, want 1", chunks)
	}
	if len(f.users) != 1 {
		t.Fatalf("%d model calls, want 1", len(f.users))
	}
	if !strings.Contains(out, "смете") || !strings.HasPrefix(out, "смета") {
		t.Errorf("summary = %q", out)
	}
}

func TestLongTranscriptIsMappedThenReduced(t *testing.T) {
	f := &roleCompleter{mapReply: "часть", reduceReply: "общий итог"}
	s := NewSummarizer(f, 40, 0, nil)
	long := strings.Repeat("Говорили про насос и трубы. ", 12)
	out, chunks, err := s.Summarize(context.Background(), "", long)
	if err != nil {
		t.Fatal(err)
	}
	if chunks < 2 {
		t.Fatalf("chunks = %d, want the transcript split", chunks)
	}
	// One map call per chunk, then exactly one reduce.
	if f.maps != chunks {
		t.Errorf("%d map calls for %d chunks", f.maps, chunks)
	}
	if f.reduces != 1 {
		t.Errorf("%d reduce calls, want 1", f.reduces)
	}
	if out != "общий итог" {
		t.Errorf("summary = %q, want the reduced text", out)
	}
}

// Losing every per-chunk summary because the last call failed would throw away
// most of the work.
func TestReduceFailureReturnsTheJoinedParts(t *testing.T) {
	f := &roleCompleter{mapReply: "часть", reduceFails: true}
	s := NewSummarizer(f, 40, 0, nil)
	long := strings.Repeat("Говорили про насос и трубы. ", 12)
	out, _, err := s.Summarize(context.Background(), "", long)
	if err == nil {
		t.Fatal("reduce failure was not reported")
	}
	if !strings.Contains(out, "часть1") || !strings.Contains(out, "часть2") {
		t.Errorf("per-chunk work was lost: %q", out)
	}
}

func TestChunkFailureIsReported(t *testing.T) {
	f := &fakeCompleter{err: errors.New("llama is down")}
	s := NewSummarizer(f, 0, 0, nil)
	if _, _, err := s.Summarize(context.Background(), "", "текст"); err == nil {
		t.Fatal("chunk failure was not reported")
	}
}

// "пусто" chunks are noise; they must not pad the reduce prompt, and a
// transcript that is entirely empty chunks is an honest ErrNoSummary rather than
// an invented summary.
func TestEmptyChunksAreDropped(t *testing.T) {
	f := &roleCompleter{mapReply: "пусто", literalMap: true, reduceReply: "не должно вызываться"}
	s := NewSummarizer(f, 40, 0, nil)
	long := strings.Repeat("Тишина в комнате. ", 12)
	if _, _, err := s.Summarize(context.Background(), "", long); !errors.Is(err, ErrNoSummary) {
		t.Fatalf("got %v, want ErrNoSummary", err)
	}
}

func TestEmptyTranscriptIsRefused(t *testing.T) {
	s := NewSummarizer(&fakeCompleter{}, 0, 0, nil)
	if _, _, err := s.Summarize(context.Background(), "", "   \n "); !errors.Is(err, ErrEmptyCapture) {
		t.Fatalf("got %v, want ErrEmptyCapture", err)
	}
}

// A summary that silently covers the first fraction of a long meeting is the
// failure mode; it has to say so.
func TestTruncationIsStatedInTheSummary(t *testing.T) {
	f := &fakeCompleter{replies: []string{"a", "b", "итог"}}
	s := NewSummarizer(f, 30, 2, nil)
	long := strings.Repeat("Говорили про насос и про трубы. ", 20)
	out, chunks, err := s.Summarize(context.Background(), "", long)
	if err != nil {
		t.Fatal(err)
	}
	if chunks != 2 {
		t.Errorf("chunks = %d, want the cap of 2", chunks)
	}
	if !strings.Contains(out, "обрезана") {
		t.Errorf("truncation not stated: %q", out)
	}
}

// The persona block belongs to the daemon, not this package, and must reach the
// model when it is supplied.
func TestContextBlockIsPrependedToEveryPrompt(t *testing.T) {
	f := &fakeCompleter{replies: []string{"итог"}}
	s := NewSummarizer(f, 0, 0, func() string { return "ПЕРСОНА\n\n" })
	if _, _, err := s.Summarize(context.Background(), "", "Обсудили смету."); err != nil {
		t.Fatal(err)
	}
	for i, sys := range f.systems {
		if !strings.HasPrefix(sys, "ПЕРСОНА") {
			t.Errorf("call %d lost the context block: %q", i, sys)
		}
	}
}

// The map/reduce prompts must contain no first person at all: the persona's
// feminine forms live in the replier, and a first-person instruction here is a
// place for the model to write "я рад".
func TestPromptsHaveNoFirstPerson(t *testing.T) {
	for name, p := range map[string]string{"chunk": chunkPrompt, "reduce": reducePrompt} {
		for _, bad := range []string{" я ", "рад", "поняла", "мне ", "вы ", "ваш"} {
			if strings.Contains(strings.ToLower(" "+p+" "), bad) {
				t.Errorf("%s prompt contains %q", name, bad)
			}
		}
	}
}

func TestChunkTextSplitsOnSentenceBoundaries(t *testing.T) {
	text := "Раз два три. Четыре пять шесть. Семь восемь девять."
	got := ChunkText(text, 20)
	if len(got) != 3 {
		t.Fatalf("got %d chunks: %q", len(got), got)
	}
	for _, c := range got {
		if !strings.HasSuffix(c, ".") {
			t.Errorf("chunk does not end on a sentence: %q", c)
		}
	}
}

func TestChunkTextPacksSentencesUpToTheLimit(t *testing.T) {
	text := "Раз. Два. Три. Четыре."
	got := ChunkText(text, 12)
	if len(got) < 2 {
		t.Fatalf("nothing was split: %q", got)
	}
	for _, c := range got {
		if n := len([]rune(c)); n > 12 {
			t.Errorf("chunk of %d runes exceeds the limit: %q", n, c)
		}
	}
}

// whisper does emit long unpunctuated runs; those must be cut on whitespace, not
// dropped and not run past the context limit.
func TestChunkTextCutsUnpunctuatedRuns(t *testing.T) {
	text := strings.TrimSpace(strings.Repeat("слово ", 50))
	got := ChunkText(text, 30)
	if len(got) < 2 {
		t.Fatalf("unpunctuated run was not split: %d chunks", len(got))
	}
	total := 0
	for _, c := range got {
		if n := len([]rune(c)); n > 30 {
			t.Errorf("chunk of %d runes exceeds the limit", n)
		}
		total += strings.Count(c, "слово")
	}
	if total != 50 {
		t.Errorf("%d of 50 words survived chunking", total)
	}
}

// A single token longer than the window must still come out, hard-cut.
func TestChunkTextHandlesOneOversizedWord(t *testing.T) {
	text := strings.Repeat("я", 70)
	got := ChunkText(text, 20)
	if len(got) != 4 {
		t.Fatalf("got %d chunks, want 4", len(got))
	}
	if joined := strings.Join(got, ""); len([]rune(joined)) != 70 {
		t.Errorf("%d runes survived, want 70", len([]rune(joined)))
	}
}

func TestChunkTextShortInputAndEmpty(t *testing.T) {
	if got := ChunkText("коротко", 100); len(got) != 1 || got[0] != "коротко" {
		t.Errorf("got %q", got)
	}
	if got := ChunkText("   ", 100); got != nil {
		t.Errorf("blank text produced %q", got)
	}
}

// roleCompleter answers by which prompt it was handed, so a test does not have
// to predict how many chunks the text splits into. Map replies are numbered
// ("часть1", "часть2", …) unless literalMap is set.
type roleCompleter struct {
	mapReply    string
	literalMap  bool
	reduceReply string
	reduceFails bool
	maps        int
	reduces     int
}

func (f *roleCompleter) Complete(_ context.Context, system, _ string) (string, error) {
	if strings.Contains(system, "конспекты фрагментов") {
		f.reduces++
		if f.reduceFails {
			return "", errors.New("llama fell over")
		}
		return f.reduceReply, nil
	}
	f.maps++
	if f.literalMap {
		return f.mapReply, nil
	}
	return fmt.Sprintf("%s%d", f.mapReply, f.maps), nil
}
