package main

// The receiving half: a nudge pushed by mavend has to reach the speaker, and
// it has to obey the same two gates a reply obeys (V-671).

import (
	"context"
	"encoding/json"
	"testing"
	"time"

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

func nudgeAudio() audio.Audio {
	return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 8000)}
}

// pushFrame builds the frame mavend's voicesink sends.
func pushFrame(t *testing.T, a audio.Audio) voice.Push {
	t.Helper()
	body, err := json.Marshal(voice.AudioNudgePush{
		RuleName: "test-rule",
		Severity: 3,
		Audio:    a,
		Text:     "пора пить воду",
		Ts:       time.Unix(0, 0),
	})
	if err != nil {
		t.Fatalf("marshal push: %v", err)
	}
	return voice.Push{Kind: voice.PushKindAudioNudge, Params: body}
}

// The defect itself: the push arrived and nothing came out of the speaker.
func TestNudgeReachesThePlayer(t *testing.T) {
	sess, p, snd := newTestSession(bargeInConfig{})
	(&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio()))

	if p.plays != 0 {
		t.Fatal("nudge played from the push goroutine; it must wait for the capture loop")
	}
	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != 1 {
		t.Fatalf("plays = %d, want 1", p.plays)
	}
	if len(p.last.Bytes) != 8000 {
		t.Errorf("played %d bytes, want the nudge audio", len(p.last.Bytes))
	}
	if sess.nudges != 1 {
		t.Errorf("nudges = %d, want 1", sess.nudges)
	}
	if len(snd.sent) != 0 {
		t.Errorf("a nudge must not be shipped back to the daemon as an utterance")
	}
}

// A push of some other kind, or one carrying no audio, must not reach the
// player and must not wedge the one that follows.
func TestNudgeIgnoresUnusablePushes(t *testing.T) {
	sess, p, _ := newTestSession(bargeInConfig{})
	h := &nudgeHandler{sess: sess}

	h.OnPush(voice.Push{Kind: "something-else", Params: json.RawMessage(`{}`)})
	h.OnPush(voice.Push{Kind: voice.PushKindAudioNudge, Params: json.RawMessage(`not json`)})
	h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono}))

	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != 0 {
		t.Fatalf("plays = %d, want 0", p.plays)
	}

	h.OnPush(pushFrame(t, nudgeAudio()))
	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != 1 {
		t.Fatalf("plays after a usable nudge = %d, want 1", p.plays)
	}
}

// The half-duplex gate covers a nudge exactly as it covers a reply: she does
// not start one over herself, and the mic stays muted while it runs.
func TestNudgeWaitsForTheReplyToFinish(t *testing.T) {
	sess, p, _ := newTestSession(bargeInConfig{})
	speakThenPause(t, sess)
	if !p.Playing() {
		t.Fatal("expected the reply to be playing")
	}
	plays := p.plays

	(&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio()))
	for i := 0; i < 20; i++ {
		if err := sess.feed(context.Background(), silentBytes()); err != nil {
			t.Fatalf("feed: %v", err)
		}
	}
	if p.plays != plays {
		t.Fatalf("nudge cut across the reply: plays = %d, want %d", p.plays, plays)
	}

	p.playing = false
	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != plays+1 {
		t.Fatalf("nudge never played after the reply ended: plays = %d", p.plays)
	}
}

// Speaking a nudge must not leave half a sentence in the VAD. The frames
// captured before it are pre-nudge speech, and splicing them onto whatever he
// says afterwards ships one utterance that is two.
func TestNudgeResetsTheVAD(t *testing.T) {
	sess, p, snd := newTestSession(bargeInConfig{})
	loud := frameAt(0.35)
	speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
	for i := 0; i < speechFrames+5; i++ {
		if err := sess.feed(context.Background(), loud); err != nil {
			t.Fatalf("feed: %v", err)
		}
	}

	(&nudgeHandler{sess: sess}).OnPush(pushFrame(t, nudgeAudio()))
	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != 1 {
		t.Fatalf("nudge did not play: plays = %d", p.plays)
	}

	// Playback ends, silence follows. The half-formed utterance must be gone
	// rather than closing on the first quiet frame.
	p.playing = false
	silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
	for i := 0; i < silenceFrames; i++ {
		if err := sess.feed(context.Background(), silentBytes()); err != nil {
			t.Fatalf("feed: %v", err)
		}
	}
	if len(snd.sent) != 0 {
		t.Fatalf("sent %d utterances after a nudge, want 0", len(snd.sent))
	}
}

// Two nudges queued back to back: the newer one is what he hears. The
// PushHandler contract in internal/voice says the next nudge replaces the
// stale one rather than dogpiling on it.
func TestNudgeReplacesAnUnspokenOne(t *testing.T) {
	sess, p, _ := newTestSession(bargeInConfig{})
	h := &nudgeHandler{sess: sess}

	h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 4000)}))
	h.OnPush(pushFrame(t, audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 12000)}))

	if err := sess.feed(context.Background(), silentBytes()); err != nil {
		t.Fatalf("feed: %v", err)
	}
	if p.plays != 1 {
		t.Fatalf("plays = %d, want 1", p.plays)
	}
	if len(p.last.Bytes) != 12000 {
		t.Errorf("played %d bytes, want the newer nudge", len(p.last.Bytes))
	}
}
