package speaker

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"time"

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

// Enroll registers a voice under an id and a spoken name.
//
// Several separate samples are required (MinEnrollSamples, MinEnrollSeconds
// total): a profile built from one sentence encodes that sentence as much as the
// person, and the resulting threshold behaviour is unpredictable. The samples
// are embedded individually and the voiceprints averaged, then re-normalised.
//
// Re-enrolling an existing id REPLACES the profile. That is the intended way to
// improve a weak one, and it is why the store upserts by id.
//
// # The refused step
//
// The plan document's fourth bullet reads "unknown speakers are enrolled on
// first interaction (prompt: 'кто это?')". That is refused. Enrolling a voice is
// taking a biometric of a person; doing it automatically the first time someone
// walks past the microphone is doing it to guests, without them being part of
// the exchange, and a TTS question into a room is not consent from whoever
// happens to answer. Enrolment here is an explicit act: an id, a name, and
// samples deliberately recorded for the purpose. An unknown voice stays unknown,
// which the rest of the system is built to cope with.
func (r *Recognizer) Enroll(ctx context.Context, id, name string, samples []audio.Audio) (Profile, error) {
	id = NormalizeID(id)
	if !ValidID(id) {
		return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
	}
	name = strings.TrimSpace(name)
	if name == "" {
		name = id
	}
	if len(samples) < MinEnrollSamples {
		return Profile{}, fmt.Errorf("%w: %d sample(s), need %d separate ones",
			ErrTooShort, len(samples), MinEnrollSamples)
	}

	var total float64
	for i, s := range samples {
		if !s.Format.IsValid() {
			return Profile{}, fmt.Errorf("%w: sample %d: %+v", ErrBadFormat, i+1, s.Format)
		}
		total += seconds(s)
	}
	if total < MinEnrollSeconds {
		return Profile{}, fmt.Errorf("%w: %.1fs total, need %.1fs",
			ErrTooShort, total, MinEnrollSeconds)
	}

	// Embed first, store second. A model failure halfway through must not leave
	// a half-built profile that would then be matched against.
	var (
		sum []float32
		dim int
	)
	for i, s := range samples {
		vec, err := r.embed(ctx, s)
		if err != nil {
			return Profile{}, fmt.Errorf("speaker: enroll %q sample %d: %w", id, i+1, err)
		}
		if sum == nil {
			sum = make([]float32, len(vec))
			dim = len(vec)
		} else if len(vec) != dim {
			// One model, one width. A mixed-width average would be nonsense.
			return Profile{}, fmt.Errorf("%w: sample %d is %d wide, expected %d",
				ErrBadVector, i+1, len(vec), dim)
		}
		for j, f := range vec {
			sum[j] += f
		}
	}
	mean, err := Normalize(sum)
	if err != nil {
		// Samples that cancel each other out to zero are not one voice.
		return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
	}

	p := Profile{
		ID:       id,
		Name:     name,
		Enrolled: r.now().UTC(),
		Samples:  len(samples),
		Dim:      dim,
		Vec:      mean,
	}
	meta := map[string]string{
		"name":     p.Name,
		"samples":  strconv.Itoa(p.Samples),
		"enrolled": p.Enrolled.Format(time.RFC3339),
		// kind marks the row for anything walking the vector table, so a future
		// export or debug page can tell a voiceprint from a note embedding
		// without parsing the id.
		"kind": "speaker",
	}
	if err := r.cat.Insert(ctx, Prefix+id, mean, meta); err != nil {
		return Profile{}, fmt.Errorf("speaker: enroll %q: %w", id, err)
	}
	return p, nil
}
