package speaker

import (
	"context"
	"fmt"
	"sort"
	"strconv"
	"time"

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

// Recognizer holds the embedder and the enrolled profiles.
//
// The profiles live in the shared vector table under the "speaker:" id prefix,
// which is what the plan asked for and what keeps them inside the encrypted
// store rather than in a sidecar file. They are read through memory.Catalog
// (ByPrefix / Delete) rather than Search, because "who is enrolled" is not a
// similarity question and note recall must never rank a voiceprint.
type Recognizer struct {
	emb       Embedder
	cat       memory.Catalog
	threshold float64
	minSec    float64
	now       func() time.Time
}

// Config — the recognizer's knobs, built from config.SpeakerConfig.
type Config struct {
	// Threshold — cosine similarity a match must beat. 0 ⇒ DefaultThreshold.
	Threshold float64
	// MinSeconds — least speech an identification will look at. 0 ⇒
	// DefaultMinSeconds.
	MinSeconds float64
}

// New builds a Recognizer. emb nil ⇒ Disabled, which is this box's state and
// makes every Identify answer ErrDisabled while enrolment and listing still
// behave sensibly (they refuse for the same reason, with the same error).
func New(emb Embedder, cat memory.Catalog, cfg Config) (*Recognizer, error) {
	if cat == nil {
		return nil, fmt.Errorf("speaker: no profile store")
	}
	if emb == nil {
		emb = Disabled{}
	}
	th := cfg.Threshold
	if th <= 0 {
		th = DefaultThreshold
	}
	min := cfg.MinSeconds
	if min <= 0 {
		min = DefaultMinSeconds
	}
	return &Recognizer{emb: emb, cat: cat, threshold: th, minSec: min, now: time.Now}, nil
}

// Enabled reports whether an embedding model is actually wired. Surfaces use it
// to say "recognition is off" once instead of failing every turn.
func (r *Recognizer) Enabled() bool {
	_, disabled := r.emb.(Disabled)
	return !disabled
}

// Threshold is the configured match floor, for a status line.
func (r *Recognizer) Threshold() float64 { return r.threshold }

// Identify names the voice in a. ErrUnknown when nothing is close enough, which
// is a normal answer and not a failure: a guest is a guest, and the caller
// carries on with no speaker attached rather than guessing.
//
// Identification never decides whether Maven listens. It annotates the turn.
func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error) {
	if !a.Format.IsValid() {
		return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
	}
	if seconds(a) < r.minSec {
		return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec)
	}
	vec, err := r.embed(ctx, a)
	if err != nil {
		return Match{}, err
	}
	profiles, err := r.List(ctx)
	if err != nil {
		return Match{}, err
	}
	if len(profiles) == 0 {
		return Match{}, ErrNoProfiles
	}

	best := Match{Score: -2}
	for _, p := range profiles {
		if s := Similarity(vec, p.Vec); s > best.Score {
			best = Match{Profile: p, Score: s}
		}
	}
	if best.Score < r.threshold {
		// The closest profile is reported in the error for a log line, because
		// "не узнала, ближе всего Ками на 0.61" is what makes a threshold
		// tunable. The caller must not use it as an identification.
		return Match{}, fmt.Errorf("%w (closest %s at %.2f, need %.2f)",
			ErrUnknown, best.Profile.ID, best.Score, r.threshold)
	}
	return best, nil
}

// List returns every enrolled profile, sorted by id so a listing is stable.
func (r *Recognizer) List(ctx context.Context) ([]Profile, error) {
	recs, err := r.cat.ByPrefix(ctx, Prefix)
	if err != nil {
		return nil, fmt.Errorf("speaker: list: %w", err)
	}
	out := make([]Profile, 0, len(recs))
	for _, rec := range recs {
		out = append(out, profileFromRecord(rec))
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out, nil
}

// Get returns one profile by id.
func (r *Recognizer) Get(ctx context.Context, id string) (Profile, error) {
	id = NormalizeID(id)
	if !ValidID(id) {
		return Profile{}, fmt.Errorf("%w: %q", ErrBadID, id)
	}
	recs, err := r.cat.ByPrefix(ctx, Prefix+id)
	if err != nil {
		return Profile{}, fmt.Errorf("speaker: get: %w", err)
	}
	for _, rec := range recs {
		if rec.ID == Prefix+id {
			return profileFromRecord(rec), nil
		}
	}
	return Profile{}, fmt.Errorf("%w: %q", ErrNotFound, id)
}

// Forget deletes a profile. This is the one operation that must always work:
// a voiceprint is data about a person, and "перестань узнавать её" has to
// actually remove it, not mark it inactive.
//
// Forgetting a profile that is not there is not an error, matching
// memory.Catalog.Delete. It used to read the row first and answer ErrNotFound,
// which meant the layer documented as the one that must always work was the
// layer reintroducing a failure: a surface retrying a forget after a partial
// failure got an error on the second try, for a voiceprint that was already
// gone. The caller asked for it to be gone and it is gone.
func (r *Recognizer) Forget(ctx context.Context, id string) error {
	id = NormalizeID(id)
	if !ValidID(id) {
		return fmt.Errorf("%w: %q", ErrBadID, id)
	}
	if err := r.cat.Delete(ctx, Prefix+id); err != nil {
		return fmt.Errorf("speaker: forget %q: %w", id, err)
	}
	return nil
}

// embed runs the model and normalises the result.
func (r *Recognizer) embed(ctx context.Context, a audio.Audio) ([]float32, error) {
	raw, err := r.emb.Embed(ctx, a)
	if err != nil {
		return nil, err
	}
	vec, err := Normalize(raw)
	if err != nil {
		return nil, err
	}
	return vec, nil
}

// profileFromRecord reads a stored row back into a Profile. A row with
// unreadable metadata still yields a usable voiceprint — the vector is the part
// that matters, and losing a name should not lose the enrolment.
//
// It also says when the metadata did not read cleanly. Without that, a row
// whose samples count is "12x" and whose name is missing came back as a
// plausible profile called by its own id with 0 samples, which is exactly what
// a real minimal enrolment looks like. Damaged is the difference between "he
// enrolled badly" and "this row is broken".
func profileFromRecord(rec memory.Record) Profile {
	p := Profile{
		ID:   trimPrefix(rec.ID),
		Vec:  rec.Vec,
		Dim:  len(rec.Vec),
		Name: rec.Meta["name"],
	}
	if s := rec.Meta["samples"]; s != "" {
		n, err := strconv.Atoi(s)
		if err != nil || n < 0 {
			p.Damaged = true
		} else {
			p.Samples = n
		}
	}
	if ts := rec.Meta["enrolled"]; ts != "" {
		t, err := time.Parse(time.RFC3339, ts)
		if err != nil {
			p.Damaged = true
		} else {
			p.Enrolled = t
		}
	}
	if p.Name == "" {
		p.Name = p.ID
		p.Damaged = true
	}
	return p
}

func trimPrefix(id string) string {
	if len(id) > len(Prefix) && id[:len(Prefix)] == Prefix {
		return id[len(Prefix):]
	}
	return id
}
