// Package speaker is voice identification (Vikunja #255,
// docs/plans/10-speaker-recognition.md).
//
// The shape is the same seam internal/vision uses: an Embedder turns audio into
// a voiceprint, a Recognizer compares one against the enrolled profiles, and a
// Disabled floor refuses politely when nothing is wired. On this box nothing is
// wired, and that is the honest state — see "Blocked" below.
//
// # A voiceprint is not like the other vectors
//
// Everything else in the vector table is something he wrote or said. A speaker
// profile is biometric data about a person, quite possibly a person who never
// asked for Maven to exist. The rules that follow from that are in the code:
//
//   - Enrolment is explicit and named. There is no "enrol the unknown voice
//     automatically" path; see the refusal in enroll.go.
//   - A profile is deletable, individually, and Forget really removes the row.
//   - Below the threshold the answer is "I do not know", never the closest
//     guess. A misattributed fact is worse than an unattributed one.
//   - Nothing here gates whether Maven listens or answers. Identification
//     annotates a turn; it never authorises one, and an unrecognised voice is
//     not turned away.
//   - Voiceprints never leave the box. They live in the encrypted store with
//     everything else and are never search input to anything external.
//
// # Blocked
//
// There is no speaker-embedding model on this box: no ECAPA-TDNN, no x-vector,
// no wespeaker or titanet ONNX anywhere under /mnt/hdd1 or models/ (checked
// 2026-08-01; the only ONNX files are the e5 text embedder and the piper voice).
// There are also no enrolment samples. So Recognizer runs against Disabled and
// every Identify answers ErrDisabled until a model lands.
//
// "Recognition is blocked but enrolment works" is not true and this package
// used to imply it. Enroll embeds every sample before it stores anything
// (enroll.go, "Embed first, store second"), so with no model it fails on the
// first sample with ErrDisabled and nothing is ever stored. List then returns
// an empty list forever and Forget has nothing to delete. Without an embedder
// all three operations are no-ops, and mavend does not wire the wire methods
// at all in that state.
//
// The MFCC + GMM "simplest floor" in the plan document is refused rather than
// deferred. A hand-rolled spectral distance would identify people confidently
// and wrongly, and its output would be written into facts as "Ками said this".
// For a biometric, a bad floor is worse than none: no answer is honest, and a
// wrong answer is a false memory about a person.
package speaker

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

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

// Prefix — the id prefix speaker profiles carry in the shared vector table.
// It is what ByPrefix enumerates, and what every Store implementation filters
// out of Search so note recall cannot rank a voiceprint. The constant lives in
// internal/memory because the store layer has to know it and cannot import this
// package.
const Prefix = memory.NonRecallPrefix

// DefaultThreshold — cosine similarity a match must beat to be a match.
//
// 0.7 is the usual operating point for ECAPA-style embeddings on clean speech
// and it is deliberately on the strict side here. The two error directions are
// not symmetric: refusing to name a voice costs a "не узнала", while naming the
// wrong person writes his wife's remark into a fact attributed to him.
const DefaultThreshold = 0.7

// DefaultMinSeconds — how much speech an identification needs. Under about two
// seconds a voiceprint is mostly noise and the similarity score is not worth
// reading.
const DefaultMinSeconds = 2.0

// MinEnrollSamples / MinEnrollSeconds — what enrolment requires. Several
// separate utterances, not one long one: a profile built from a single sentence
// encodes that sentence's prosody as much as the voice.
const (
	MinEnrollSamples = 3
	MinEnrollSeconds = 9.0
)

// Errors callers distinguish.
var (
	// ErrDisabled — no embedding model is wired. The state of this box.
	ErrDisabled = errors.New("speaker: recognition is not configured")
	// ErrTooShort — not enough speech to say anything about.
	ErrTooShort = errors.New("speaker: not enough audio")
	// ErrUnknown — audio embedded fine, but no enrolled profile is close
	// enough. Not an error in the sense of something being broken: it is the
	// correct answer for a guest, and the caller should carry on without a
	// speaker rather than treat the turn as failed.
	ErrUnknown = errors.New("speaker: voice not recognised")
	// ErrNoProfiles — nobody is enrolled yet.
	ErrNoProfiles = errors.New("speaker: nobody is enrolled")
	// ErrNotFound — no profile with that id.
	ErrNotFound = errors.New("speaker: no such profile")
	// ErrBadID — an id that is empty or carries characters an id should not.
	ErrBadID = errors.New("speaker: invalid profile id")
	// ErrBadFormat — audio that is not the canonical 16 kHz mono PCM shape.
	ErrBadFormat = errors.New("speaker: audio format not supported")
	// ErrBadVector — an embedder returned something unusable (empty, or all
	// zeroes, which normalises to nothing and would match everything equally).
	ErrBadVector = errors.New("speaker: embedder returned an unusable vector")
)

// Embedder turns speech into a voiceprint. Implementations are expected to
// return an L2-normalised vector, because the whole store compares by dot
// product; Normalize is applied anyway rather than trusted.
//
// This is the seam a downloaded ECAPA-TDNN ONNX model plugs into. It is an
// interface rather than a concrete ONNX type so the package is testable with no
// model on disk, which is the only way it could be tested here at all.
type Embedder interface {
	Embed(ctx context.Context, a audio.Audio) ([]float32, error)
	// Dim is the vector width, used to reject a profile recorded with a
	// different model rather than silently scoring it as zero.
	Dim() int
}

// Disabled is the floor: no model, no answers, no guesses.
type Disabled struct{}

// Embed always fails with ErrDisabled.
func (Disabled) Embed(context.Context, audio.Audio) ([]float32, error) { return nil, ErrDisabled }

// Dim is 0 for the disabled embedder.
func (Disabled) Dim() int { return 0 }

// Profile — one enrolled voice.
//
// Name is what she calls the person out loud ("Ками"). ID is the stable handle
// used in sources and metadata. Samples records how many utterances the
// voiceprint was averaged from, so a profile enrolled from the bare minimum is
// visibly weaker than one built from ten.
type Profile struct {
	ID       string    `json:"id"`
	Name     string    `json:"name"`
	Enrolled time.Time `json:"enrolled"`
	Samples  int       `json:"samples"`
	Dim      int       `json:"dim"`

	// Damaged marks a row whose stored metadata did not read back cleanly —
	// an unparsable sample count or timestamp, or a missing name. The
	// voiceprint is still usable, but the row should be listed as damaged
	// rather than as a plausible profile enrolled from nothing.
	Damaged bool `json:"damaged,omitempty"`

	// Vec is the voiceprint. Not serialised to any surface: a listing tells him
	// who is enrolled, it does not hand out the biometric itself.
	Vec []float32 `json:"-"`
}

// Source is what a fact or note written during this speaker's turn is tagged
// with, e.g. "tap:voice:speaker:kami". Attribution belongs in the source rather
// than in the text, so it can be corrected or dropped later without rewriting
// what was said.
func (p Profile) Source(base string) string {
	if p.ID == "" {
		return base
	}
	return base + ":" + Prefix + p.ID
}

// Match — an identification result. Score is cosine similarity in [-1, 1].
type Match struct {
	Profile Profile
	Score   float64
}

// ValidID reports whether an id is usable as a profile handle. Deliberately
// narrow: lowercase letters, digits, dash and underscore. Ids end up in note
// sources and in vector-table keys, so a permissive id would be a way to write
// into a neighbouring key space.
func ValidID(id string) bool {
	if id == "" || len(id) > 64 {
		return false
	}
	for _, r := range id {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
		default:
			return false
		}
	}
	return true
}

// NormalizeID lowercases and trims a proposed id before validating it, so
// "Ками" typed as "Kami " does not fail for a reason nobody can see.
func NormalizeID(id string) string {
	return strings.ToLower(strings.TrimSpace(id))
}

// Normalize returns an L2-normalised copy of v, or ErrBadVector when there is
// nothing to normalise. A zero vector is refused rather than passed on: it
// scores 0 against everything, which reads as "no match" but for the wrong
// reason and would hide a broken embedder.
func Normalize(v []float32) ([]float32, error) {
	if len(v) == 0 {
		return nil, ErrBadVector
	}
	var sum float64
	for _, f := range v {
		if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) {
			return nil, ErrBadVector
		}
		sum += float64(f) * float64(f)
	}
	norm := math.Sqrt(sum)
	if norm == 0 {
		return nil, ErrBadVector
	}
	out := make([]float32, len(v))
	for i, f := range v {
		out[i] = float32(float64(f) / norm)
	}
	return out, nil
}

// Similarity is the cosine similarity of two L2-normalised vectors. Different
// widths score 0: a profile enrolled with another model must not accidentally
// match, and 0 is below every sane threshold.
func Similarity(a, b []float32) float64 {
	if len(a) != len(b) || len(a) == 0 {
		return 0
	}
	var sum float64
	for i := range a {
		sum += float64(a[i]) * float64(b[i])
	}
	return sum
}

// seconds is the playback length of a frame, for the minimum-audio checks.
func seconds(a audio.Audio) float64 { return a.Duration() }
