package speaker

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

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

// fakeEmbedder returns a fixed vector per "voice", so a test can enrol one
// person and present another without a model. Wobble adds a small perturbation
// so repeated samples of one voice are close but not identical, which is what a
// real embedder produces.
type fakeEmbedder struct {
	vec    []float32
	err    error
	calls  int
	wobble float32
}

func (f *fakeEmbedder) Embed(_ context.Context, _ audio.Audio) ([]float32, error) {
	f.calls++
	if f.err != nil {
		return nil, f.err
	}
	out := append([]float32(nil), f.vec...)
	if f.wobble != 0 && len(out) > 1 {
		out[0] += f.wobble * float32(f.calls)
		out[1] -= f.wobble * float32(f.calls)
	}
	return out, nil
}

func (f *fakeEmbedder) Dim() int { return len(f.vec) }

// speech builds n seconds of the canonical audio shape.
func speech(sec float64) audio.Audio {
	return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, int(sec*16000)*2)}
}

func newRec(t *testing.T, emb Embedder) (*Recognizer, memory.Catalog) {
	t.Helper()
	cat := memory.NewInMemoryStore()
	r, err := New(emb, cat, Config{})
	if err != nil {
		t.Fatal(err)
	}
	return r, cat
}

func enrolSamples(n int, sec float64) []audio.Audio {
	out := make([]audio.Audio, n)
	for i := range out {
		out[i] = speech(sec)
	}
	return out
}

// The state of this box: no model on disk. Every identification refuses rather
// than guessing, and it says why.
func TestDisabledRefusesEverything(t *testing.T) {
	r, _ := newRec(t, nil)
	if r.Enabled() {
		t.Error("a recognizer with no model reports itself enabled")
	}
	if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrDisabled) {
		t.Errorf("Identify = %v, want ErrDisabled", err)
	}
	if _, err := r.Enroll(context.Background(), "kami", "Ками", enrolSamples(3, 4)); !errors.Is(err, ErrDisabled) {
		t.Errorf("Enroll = %v, want ErrDisabled", err)
	}
	// Listing still works: knowing that nobody is enrolled needs no model.
	got, err := r.List(context.Background())
	if err != nil || len(got) != 0 {
		t.Errorf("List = %v, %v", got, err)
	}
}

func TestNewRequiresAProfileStore(t *testing.T) {
	if _, err := New(nil, nil, Config{}); err == nil {
		t.Error("built a recognizer with nowhere to keep profiles")
	}
}

func TestEnrollThenIdentify(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}, wobble: 0.01}
	r, _ := newRec(t, emb)
	ctx := context.Background()

	p, err := r.Enroll(ctx, "Kami ", "Ками", enrolSamples(3, 4))
	if err != nil {
		t.Fatalf("enroll: %v", err)
	}
	if p.ID != "kami" {
		t.Errorf("id = %q, want the normalised %q", p.ID, "kami")
	}
	if p.Name != "Ками" || p.Samples != 3 || p.Dim != 4 {
		t.Errorf("profile = %+v", p)
	}

	m, err := r.Identify(ctx, speech(5))
	if err != nil {
		t.Fatalf("identify: %v", err)
	}
	if m.Profile.ID != "kami" || m.Profile.Name != "Ками" {
		t.Errorf("match = %+v", m)
	}
	if m.Score < r.Threshold() {
		t.Errorf("score %.3f is below the threshold it supposedly passed", m.Score)
	}
}

// The error direction that matters. Naming the wrong person writes a false
// memory about them, so a voice that is not close enough gets no name at all.
func TestUnfamiliarVoiceIsNotGuessed(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0, 0, 0}}
	r, _ := newRec(t, emb)
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}

	// A different voice: orthogonal voiceprint, similarity 0.
	emb.vec = []float32{0, 1, 0, 0}
	m, err := r.Identify(ctx, speech(5))
	if !errors.Is(err, ErrUnknown) {
		t.Fatalf("Identify = %v, want ErrUnknown", err)
	}
	if m.Profile.ID != "" {
		t.Errorf("a refused identification still handed back %q", m.Profile.ID)
	}
	// The log line needs the near miss to make the threshold tunable.
	if !contains(err.Error(), "kami") {
		t.Errorf("error does not name the closest profile: %v", err)
	}
}

// Just under the threshold is still unknown. A boundary this important gets its
// own test rather than being implied.
func TestThresholdIsAFloorNotASuggestion(t *testing.T) {
	cat := memory.NewInMemoryStore()
	emb := &fakeEmbedder{vec: []float32{1, 0}}
	r, err := New(emb, cat, Config{Threshold: 0.9})
	if err != nil {
		t.Fatal(err)
	}
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	// cos ≈ 0.866, comfortably similar and still not similar enough.
	emb.vec = []float32{0.866, 0.5}
	if _, err := r.Identify(ctx, speech(5)); !errors.Is(err, ErrUnknown) {
		t.Fatalf("0.866 against a 0.9 threshold = %v, want ErrUnknown", err)
	}
}

func TestShortAudioIsRefusedBeforeTheModelRuns(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0}}
	r, _ := newRec(t, emb)
	if _, err := r.Identify(context.Background(), speech(0.5)); !errors.Is(err, ErrTooShort) {
		t.Fatalf("got %v, want ErrTooShort", err)
	}
	if emb.calls != 0 {
		t.Error("a half-second of audio was sent to the model anyway")
	}
}

func TestWrongAudioFormatIsRefused(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	bad := audio.Audio{
		Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"},
		Bytes:  make([]byte, 44100*4*5),
	}
	if _, err := r.Identify(context.Background(), bad); !errors.Is(err, ErrBadFormat) {
		t.Fatalf("got %v, want ErrBadFormat", err)
	}
}

func TestIdentifyWithNobodyEnrolled(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	if _, err := r.Identify(context.Background(), speech(5)); !errors.Is(err, ErrNoProfiles) {
		t.Fatalf("got %v, want ErrNoProfiles", err)
	}
}

// Enrolment is an explicit act with real samples behind it, not a byproduct of
// someone speaking once.
func TestEnrollmentRequiresSeveralRealSamples(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	ctx := context.Background()
	cases := []struct {
		name    string
		samples []audio.Audio
	}{
		{"one long sample", enrolSamples(1, 30)},
		{"two samples", enrolSamples(2, 10)},
		{"three samples but seconds of audio", enrolSamples(3, 1)},
		{"none at all", nil},
	}
	for _, c := range cases {
		if _, err := r.Enroll(ctx, "kami", "Ками", c.samples); !errors.Is(err, ErrTooShort) {
			t.Errorf("%s: %v, want ErrTooShort", c.name, err)
		}
	}
}

func TestEnrollRejectsBadIDs(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	for _, id := range []string{"", "  ", "../etc/passwd", "speaker:kami", "имя", "a/b", "x y"} {
		if _, err := r.Enroll(context.Background(), id, "n", enrolSamples(3, 4)); !errors.Is(err, ErrBadID) {
			t.Errorf("id %q accepted or wrong error: %v", id, err)
		}
	}
}

// Re-enrolling replaces the voiceprint. Leaving the old one searchable would
// mean a person's rejected profile keeps matching them.
func TestReEnrollReplaces(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0, 0}}
	r, _ := newRec(t, emb)
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	emb.vec = []float32{0, 1, 0}
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(4, 4)); err != nil {
		t.Fatal(err)
	}
	list, err := r.List(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 1 {
		t.Fatalf("%d profiles after re-enrolling one person", len(list))
	}
	if list[0].Samples != 4 {
		t.Errorf("sample count = %d, want the new 4", list[0].Samples)
	}
	// The new voiceprint is the one that matches.
	if m, err := r.Identify(ctx, speech(5)); err != nil || m.Score < 0.99 {
		t.Errorf("identify after re-enrol: %v (score %.3f)", err, m.Score)
	}
}

// "Перестань узнавать её" has to actually delete the biometric.
func TestForgetRemovesTheVoiceprint(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0}}
	r, cat := newRec(t, emb)
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "guest", "Гостья", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	if err := r.Forget(ctx, "Guest "); err != nil {
		t.Fatalf("forget: %v", err)
	}
	recs, err := cat.ByPrefix(ctx, Prefix)
	if err != nil {
		t.Fatal(err)
	}
	if len(recs) != 0 {
		t.Errorf("%d row(s) survived Forget", len(recs))
	}
	if _, err := r.Get(ctx, "guest"); !errors.Is(err, ErrNotFound) {
		t.Errorf("Get after Forget = %v, want ErrNotFound", err)
	}
	// Forgetting twice is not an error. A surface retrying after a partial
	// failure must not be told the voice it asked to remove is missing.
	if err := r.Forget(ctx, "guest"); err != nil {
		t.Errorf("second Forget = %v, want nil", err)
	}
}

// A row whose metadata is corrupt lists as damaged, not as a plausible profile
// enrolled from zero samples. Those two used to look identical.
func TestCorruptMetadataListsAsDamaged(t *testing.T) {
	r, cat := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	ctx := context.Background()
	if err := cat.Insert(ctx, Prefix+"kami", []float32{1, 0}, map[string]string{
		"kind":     "speaker",
		"samples":  "12x",
		"enrolled": "yesterday",
	}); err != nil {
		t.Fatal(err)
	}
	ps, err := r.List(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(ps) != 1 {
		t.Fatalf("List returned %d profiles, want 1", len(ps))
	}
	p := ps[0]
	if !p.Damaged {
		t.Errorf("profile %+v is not marked damaged", p)
	}
	if p.Samples != 0 || !p.Enrolled.IsZero() {
		t.Errorf("unreadable metadata was parsed anyway: samples %d, enrolled %v", p.Samples, p.Enrolled)
	}
	if len(p.Vec) != 2 {
		t.Errorf("the voiceprint was dropped: %v", p.Vec)
	}
}

// A clean row is not damaged. Without this the flag could be set always and
// the test above would still pass.
func TestCleanProfileIsNotDamaged(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	ps, err := r.List(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(ps) != 1 || ps[0].Damaged {
		t.Fatalf("List = %+v, want one undamaged profile", ps)
	}
}

// Voiceprints share the vector table with note and fact embeddings, so the
// prefix has to actually partition it.
func TestProfilesDoNotCollideWithNoteVectors(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0}}
	r, cat := newRec(t, emb)
	ctx := context.Background()
	if err := cat.Insert(ctx, "note:1", []float32{1, 0}, map[string]string{"text": "заметка"}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	list, err := r.List(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 1 || list[0].ID != "kami" {
		t.Errorf("listing picked up a non-speaker row: %+v", list)
	}
	// And an identical note vector is never returned as a match.
	m, err := r.Identify(ctx, speech(5))
	if err != nil {
		t.Fatal(err)
	}
	if m.Profile.ID != "kami" {
		t.Errorf("matched %q", m.Profile.ID)
	}
}

func TestEmbedderFailurePropagates(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0}, err: errors.New("onnx fell over")}
	r, _ := newRec(t, emb)
	if _, err := r.Identify(context.Background(), speech(5)); err == nil {
		t.Error("a model failure was reported as a successful identification")
	}
	if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); err == nil {
		t.Error("a model failure produced a profile")
	}
}

// A zero vector scores 0 against everything, which reads as "no match" for the
// wrong reason and would hide a broken model.
func TestUnusableVectorsAreRefused(t *testing.T) {
	r, _ := newRec(t, &fakeEmbedder{vec: []float32{0, 0, 0}})
	if _, err := r.Enroll(context.Background(), "kami", "К", enrolSamples(3, 4)); !errors.Is(err, ErrBadVector) {
		t.Errorf("zero vector: %v, want ErrBadVector", err)
	}
	if _, err := Normalize(nil); !errors.Is(err, ErrBadVector) {
		t.Errorf("empty: %v", err)
	}
	if _, err := Normalize([]float32{float32(nan())}); !errors.Is(err, ErrBadVector) {
		t.Errorf("NaN: %v", err)
	}
}

func TestNormalizeProducesAUnitVector(t *testing.T) {
	v, err := Normalize([]float32{3, 4})
	if err != nil {
		t.Fatal(err)
	}
	if got := Similarity(v, v); got < 0.999 || got > 1.001 {
		t.Errorf("self-similarity = %f, want 1", got)
	}
}

// A profile enrolled with another model must not accidentally match.
func TestDifferentWidthsScoreZero(t *testing.T) {
	if got := Similarity([]float32{1, 0}, []float32{1, 0, 0}); got != 0 {
		t.Errorf("mismatched widths scored %f", got)
	}
}

// Attribution belongs in the source, so it can be corrected without rewriting
// what was said.
func TestProfileSource(t *testing.T) {
	p := Profile{ID: "kami"}
	if got := p.Source("tap:voice"); got != "tap:voice:speaker:kami" {
		t.Errorf("source = %q", got)
	}
	var anon Profile
	if got := anon.Source("tap:voice"); got != "tap:voice" {
		t.Errorf("unattributed source = %q, want the base unchanged", got)
	}
}

func TestValidID(t *testing.T) {
	for _, ok := range []string{"kami", "guest-2", "a_b", "x"} {
		if !ValidID(ok) {
			t.Errorf("%q rejected", ok)
		}
	}
	for _, bad := range []string{"", "Kami", "имя", "a b", "a/b", "a:b", "..", strings.Repeat("a", 65)} {
		if ValidID(bad) {
			t.Errorf("%q accepted", bad)
		}
	}
}

func TestProfileMetadataSurvivesARoundTrip(t *testing.T) {
	emb := &fakeEmbedder{vec: []float32{1, 0}}
	r, _ := newRec(t, emb)
	r.now = func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }
	ctx := context.Background()
	if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
		t.Fatal(err)
	}
	got, err := r.Get(ctx, "kami")
	if err != nil {
		t.Fatal(err)
	}
	if got.Name != "Ками" || got.Samples != 3 {
		t.Errorf("profile = %+v", got)
	}
	if !got.Enrolled.Equal(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)) {
		t.Errorf("enrolled = %v", got.Enrolled)
	}
}

func contains(s, sub string) bool { return strings.Contains(s, sub) }

func nan() float64 { return math.NaN() }
