package capture

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

	"github.com/kami/maven/internal/audio"
	"github.com/kami/maven/internal/media"
)

// fakeTranscriber returns a fixed phrase per call so a windowed transcription is
// visible in the joined output.
type fakeTranscriber struct {
	calls  int
	err    error
	phrase string
}

func (f *fakeTranscriber) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) {
	f.calls++
	if f.err != nil {
		return "", 0, f.err
	}
	p := f.phrase
	if p == "" {
		p = "окно"
	}
	return fmt.Sprintf("%s%d", p, f.calls), 1.0, nil
}

// fakeCompleter records prompts and replies from a script.
type fakeCompleter struct {
	replies []string
	systems []string
	users   []string
	err     error
}

func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
	f.systems = append(f.systems, system)
	f.users = append(f.users, user)
	if f.err != nil {
		return "", f.err
	}
	if len(f.replies) == 0 {
		return "итог", nil
	}
	r := f.replies[0]
	f.replies = f.replies[1:]
	return r, nil
}

// frame builds n seconds of silence in the canonical format.
func frame(seconds float64) audio.Audio {
	n := int(seconds*16000) * 2
	return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, n)}
}

func testRecorder(t *testing.T, tr *fakeTranscriber, sum *Summarizer, cfg Config) (*Recorder, *media.Store) {
	t.Helper()
	blobs, err := media.Open(t.TempDir(), 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	r, err := New(blobs, tr, sum, cfg)
	if err != nil {
		t.Fatal(err)
	}
	return r, blobs
}

func TestNewRequiresStoreAndTranscriber(t *testing.T) {
	blobs, err := media.Open(t.TempDir(), 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := New(nil, &fakeTranscriber{}, nil, Config{}); err == nil {
		t.Error("recorder built with no blob store")
	}
	if _, err := New(blobs, nil, nil, Config{}); err == nil {
		t.Error("recorder built with no transcriber")
	}
}

// The invariant that matters most: audio arriving at a recorder nobody started
// is refused. There is no ambient path in.
func TestAppendWithoutStartIsRefused(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	if err := r.Append("no-token", frame(1)); !errors.Is(err, ErrNoSession) {
		t.Fatalf("got %v, want ErrNoSession", err)
	}
	if r.Status().Running {
		t.Error("a refused frame started a session")
	}
}

func TestStopWithoutStartIsRefused(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	if _, err := r.Stop(context.Background(), "no-token"); !errors.Is(err, ErrNoSession) {
		t.Fatalf("got %v, want ErrNoSession", err)
	}
}

func TestOneSessionAtATime(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	s, err := r.Start("встреча")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
		t.Fatalf("got %v, want ErrBusy", err)
	}
	if _, err := r.Stop(context.Background(), s.Token); !errors.Is(err, ErrEmptyCapture) {
		t.Fatalf("empty stop: %v", err)
	}
	// The slot is free again after a stop, even a failed one.
	if _, err := r.Start("третья"); err != nil {
		t.Errorf("slot not released: %v", err)
	}
}

func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
	tr := &fakeTranscriber{phrase: "совещание"}
	sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
	r, blobs := testRecorder(t, tr, sum, Config{})

	s, err := r.Start("встреча с подрядчиком")
	if err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 3; i++ {
		if err := r.Append(s.Token, frame(2)); err != nil {
			t.Fatal(err)
		}
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err != nil {
		t.Fatalf("stop: %v", err)
	}
	if err := r.Summarize(context.Background(), &res); err != nil {
		t.Fatalf("summarize: %v", err)
	}
	if res.BlobID == "" {
		t.Error("no audio blob stored")
	}
	blob, data, err := blobs.Read(res.BlobID)
	if err != nil {
		t.Fatalf("blob unreadable: %v", err)
	}
	if blob.Kind != media.KindAudio || blob.Source != "capture:meeting" {
		t.Errorf("blob metadata = %+v", blob)
	}
	if string(data[:4]) != "RIFF" {
		t.Error("audio was not stored as a playable WAV")
	}
	if res.Transcript == "" {
		t.Error("no transcript")
	}
	if !strings.Contains(res.Summary, "насос") {
		t.Errorf("summary = %q", res.Summary)
	}
	if !strings.Contains(res.Summary, "встреча с подрядчиком") {
		t.Errorf("label missing from summary: %q", res.Summary)
	}
	if res.Duration != 6*time.Second {
		t.Errorf("duration = %v, want 6s", res.Duration)
	}
}

// A forgotten session stops itself, and the audio collected before the cap is
// kept rather than thrown away.
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
	tr := &fakeTranscriber{}
	r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
	s, err := r.Start("длинная")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(3)); err != nil {
		t.Fatalf("first frame: %v", err)
	}
	if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
		t.Fatalf("got %v, want ErrExpired", err)
	}
	// Further frames keep being refused, so a client that ignores the error
	// cannot grow the recording past the cap.
	if err := r.Append(s.Token, frame(3)); !errors.Is(err, ErrExpired) {
		t.Fatalf("post-expiry frame: %v", err)
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err != nil {
		t.Fatalf("stop after expiry: %v", err)
	}
	if res.Duration != 6*time.Second {
		t.Errorf("duration = %v, want the 6s collected before the cap", res.Duration)
	}
}

func TestAppendRejectsWrongFormat(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	s, err := r.Start("x")
	if err != nil {
		t.Fatal(err)
	}
	bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
	if err := r.Append(s.Token, bad); !errors.Is(err, ErrBadFormat) {
		t.Fatalf("got %v, want ErrBadFormat", err)
	}
}

// "забудь, не записывай" must leave nothing behind — no blob, no transcript.
func TestAbortLeavesNothing(t *testing.T) {
	tr := &fakeTranscriber{}
	r, blobs := testRecorder(t, tr, nil, Config{})
	s, err := r.Start("зря начали")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(5)); err != nil {
		t.Fatal(err)
	}
	if !r.Abort(s.Token) {
		t.Fatal("Abort reported nothing running")
	}
	if r.Status().Running {
		t.Error("session survived Abort")
	}
	list, err := blobs.List(media.KindAudio)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 0 {
		t.Errorf("Abort stored %d blob(s)", len(list))
	}
	if tr.calls != 0 {
		t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
	}
	if r.Abort(s.Token) {
		t.Error("second Abort reported a session")
	}
}

func TestStatusReportsTheRunningSession(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	if got := r.Status(); got.Running {
		t.Error("idle recorder reports running")
	}
	s, err := r.Start("планёрка")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(10)); err != nil {
		t.Fatal(err)
	}
	st := r.Status()
	if !st.Running || st.Label != "планёрка" {
		t.Fatalf("status = %+v", st)
	}
	if st.Duration != 10*time.Second {
		t.Errorf("duration = %v", st.Duration)
	}
	if st.Bytes != 10*16000*2 {
		t.Errorf("bytes = %d", st.Bytes)
	}
}

// Long audio goes to the transcriber in windows: handing a whisper worker an
// hour of PCM in one call blocks the voice path for minutes.
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
	tr := &fakeTranscriber{}
	r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
	s, err := r.Start("длинная")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(9)); err != nil {
		t.Fatal(err)
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err != nil {
		t.Fatalf("stop: %v", err)
	}
	if tr.calls != 5 { // 2+2+2+2+1
		t.Errorf("transcriber called %d times, want 5", tr.calls)
	}
	if !strings.Contains(res.Transcript, "окно5") {
		t.Errorf("last window missing from transcript: %q", res.Transcript)
	}
}

// Every window failing is a transcription failure — but the audio is already
// stored and re-runnable.
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
	tr := &fakeTranscriber{err: errors.New("whisper is down")}
	r, blobs := testRecorder(t, tr, nil, Config{})
	s, err := r.Start("встреча")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(2)); err != nil {
		t.Fatal(err)
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err == nil {
		t.Fatal("transcription failure was not reported")
	}
	if res.BlobID == "" {
		t.Fatal("no blob id to retry with")
	}
	if _, _, err := blobs.Read(res.BlobID); err != nil {
		t.Errorf("audio was not kept: %v", err)
	}
}

// No llama-server ⇒ transcript only. That is the honest degradation, not an
// error.
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
	r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
	s, err := r.Start("встреча")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(1)); err != nil {
		t.Fatal(err)
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err != nil {
		t.Fatalf("stop: %v", err)
	}
	if err := r.Summarize(context.Background(), &res); err != nil {
		t.Fatalf("summarize with no summarizer: %v", err)
	}
	if res.Transcript == "" {
		t.Error("no transcript")
	}
	if res.Summary != "" {
		t.Errorf("summary appeared from nowhere: %q", res.Summary)
	}
}

// A summariser failure is a degraded success: the words exist and are returned.
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
	sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
	r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
	s, err := r.Start("встреча")
	if err != nil {
		t.Fatal(err)
	}
	if err := r.Append(s.Token, frame(1)); err != nil {
		t.Fatal(err)
	}
	res, err := r.Stop(context.Background(), s.Token)
	if err != nil {
		t.Fatalf("stop: %v", err)
	}
	if err := r.Summarize(context.Background(), &res); err == nil {
		t.Fatal("summary failure was not reported")
	}
	if res.Transcript == "" {
		t.Error("transcript lost to a summary failure")
	}
}

func TestWindowBytesNeverCutsMidSample(t *testing.T) {
	if got := windowBytes(audio.PCM16kMono, 2*time.Second); got%2 != 0 || got != 2*16000*2 {
		t.Fatalf("windowBytes = %d", got)
	}
	odd := audio.Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}
	if got := windowBytes(odd, 0); got != 0 {
		t.Fatalf("a zero window must produce zero, got %d", got)
	}
}
