//go:build windows

package audio

import (
	"encoding/binary"
	"fmt"
	"runtime/debug"
	"strings"
	"sync"
	"time"
)

// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}

// AlertSink receives the few audio problems an operator must see WHILE they are
// operating, not afterwards in a log file — a capture device that opens but
// never streams being the one that matters: the recording is silently empty and
// nothing says so until the QSO is logged and gone.
//
// Set to a toast emitter at startup; a no-op keeps the package standalone.
var AlertSink = func(string, ...any) {}

// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
	if r := recover(); r != nil {
		LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
	}
}

// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
// single mono track, so both sides of the contact are captured.
//
// Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots
// the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO()
// drops it. Stop() tears down capture.
type Recorder struct {
	mu      sync.Mutex
	stopCh  chan struct{}
	wg      sync.WaitGroup
	running bool

	prerollSamples int

	// Per-source sample queues (guarded by srcMu), drained by the mixer.
	srcMu sync.Mutex
	bufA  []int16 // From Radio
	bufB  []int16 // mic
	// When each source last delivered samples. A configured device that never
	// produces anything is not an error anywhere — the capture call just sits
	// there — so the only way to notice is to watch the clock.
	lastA, lastB time.Time
	// startedAt is when capture began — the reference for a source that has not
	// delivered anything at all yet.
	startedAt time.Time
	// deadB (deadA) latches once a source has been declared silent, so the
	// warning is logged once rather than 25 times a second.
	deadA, deadB bool
	twoSrc       bool
	gainA        float64 // From Radio gain (1.0 = unity), guarded by srcMu
	gainB        float64 // mic gain

	// Mixed output state (guarded by mu).
	ring   []int16 // last prerollSamples of mixed audio
	active bool
	// paused freezes an ACTIVE take: nothing more is accumulated, but everything
	// captured so far is kept and the take still ends normally when the QSO is
	// logged. It is what lets an operator stop, play the recording back on the
	// air to the station they are working, and still have it saved with the QSO.
	paused bool
	acc    []int16 // active QSO accumulation (seeded from ring on BeginQSO)
}

func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} }

// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a
// hot mic against quieter rig RX audio. Values ≤0 fall back to unity.
func (r *Recorder) SetGains(fromGain, micGain float64) {
	if fromGain <= 0 {
		fromGain = 1
	}
	if micGain <= 0 {
		micGain = 1
	}
	r.srcMu.Lock()
	r.gainA, r.gainB = fromGain, micGain
	r.srcMu.Unlock()
}

// scaleSample applies gain to a sample with clamping.
func scaleSample(s int16, g float64) int16 {
	if g == 1 {
		return s
	}
	v := float64(s) * g
	if v > 32767 {
		return 32767
	}
	if v < -32768 {
		return -32768
	}
	return int16(v)
}

func (r *Recorder) Running() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.running
}

func (r *Recorder) Active() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.active
}

// Start begins continuous capture from fromDev (required) mixed with micDev
// (optional — "" or same as fromDev → single source). prerollSec is how much
// audio to retain ahead of BeginQSO.
func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
	r.mu.Lock()
	if r.running {
		r.mu.Unlock()
		return nil
	}
	if prerollSec < 0 {
		prerollSec = 0
	}
	// Does the configured endpoint still EXIST?
	//
	// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
	// or removed comes back with a different id. The old one then opens without
	// complaint on some drivers and simply never streams — which is
	// indistinguishable from a quiet band until the recording turns out empty.
	// Checking the list takes milliseconds and answers it outright.
	if devs, derr := ListInputDevices(); derr == nil {
		known := func(id string) bool {
			for _, d := range devs {
				if d.ID == id {
					return true
				}
			}
			return false
		}
		if fromDev != "" && !known(fromDev) {
			LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
			AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
		}
		if micDev != "" && micDev != fromDev && !known(micDev) {
			LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
		}
	}
	r.prerollSamples = prerollSec * sampleRate
	r.twoSrc = micDev != "" && micDev != fromDev
	r.stopCh = make(chan struct{})
	r.running = true
	r.startedAt = time.Now()
	r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
	r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
	stop := r.stopCh
	twoSrc := r.twoSrc
	r.mu.Unlock()

	// The radio side is either CAPTURED from a sound device or PUSHED in from
	// somewhere else. Over the network there is no sound device at all: an
	// IC-705 reached by LAN streams its receive audio over the Icom protocol,
	// and the operator's audio settings can only offer the PC's own microphone
	// and speakers. fromDev == PushedSource says "someone else will feed me",
	// and PushRX is how they do it.
	if fromDev == PushedSource {
		LogSink("recorder: radio audio is pushed in (network), not captured from a device")
	} else {
		r.startRadioCapture(fromDev, stop)
	}
	if twoSrc {
		r.startMicCapture(micDev, stop)
	}
	r.finishStart(fromDev, micDev, twoSrc, stop)
	return nil
}

// PushedSource is the "device" name that means the radio audio arrives through
// PushRX instead of a capture stream.
const PushedSource = "\x00pushed"

// PushRX feeds one chunk of 16-bit mono PCM from a non-device source.
//
// Safe to call when nothing is recording — the samples are dropped, which is
// what should happen: the network stream runs whenever the rig is connected,
// and the recorder only wants it between BeginQSO and the save.
func (r *Recorder) PushRX(pcm []byte) {
	r.mu.Lock()
	running := r.running
	r.mu.Unlock()
	if !running || len(pcm) == 0 {
		return
	}
	sm := bytesToInt16(pcm)
	r.srcMu.Lock()
	r.bufA = append(r.bufA, sm...)
	r.lastA = time.Now()
	r.srcMu.Unlock()
}

func (r *Recorder) startRadioCapture(fromDev string, stop chan struct{}) {
	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		defer recoverGoroutine("recorder capture (radio)")
		// The error was discarded here. A device that cannot be opened — renamed,
		// unplugged, held by another program — then looked exactly like a device
		// that is merely quiet, and the recording came out empty with nothing in
		// the log to say why.
		if err := captureStream(fromDev, stop, func(chunk []byte) {
			s := bytesToInt16(chunk)
			r.srcMu.Lock()
			r.bufA = append(r.bufA, s...)
			r.lastA = time.Now()
			r.srcMu.Unlock()
		}); err != nil {
			LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
		}
	}()
}

func (r *Recorder) startMicCapture(micDev string, stop chan struct{}) {
	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		defer recoverGoroutine("recorder capture (mic)")
		if err := captureStream(micDev, stop, func(chunk []byte) {
			s := bytesToInt16(chunk)
			r.srcMu.Lock()
			r.bufB = append(r.bufB, s...)
			r.lastB = time.Now()
			r.srcMu.Unlock()
		}); err != nil {
			LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
		}
	}()
}

// finishStart logs what is being recorded and arms the silence watchdog.
func (r *Recorder) finishStart(fromDev, micDev string, twoSrc bool, stop chan struct{}) {
	// Name the devices being recorded FROM, once, at the start.
	//
	// A station with two radios has two sets of endpoints, and the recorder will
	// happily capture the one that is not being listened to: the result is a
	// file full of hiss with no relation to what the operator hears, and nothing
	// anywhere said which receiver it came from.
	if twoSrc {
		LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
	} else {
		LogSink("recorder: capturing %q", DeviceName(fromDev))
	}

	// Watchdog. A WASAPI device can open cleanly and then produce nothing at
	// all — a DAX channel with no stream behind it does exactly that, and it is
	// indistinguishable from silence until the recording turns out to be empty
	// at the end of a QSO. Say it once, three seconds in, while there is still
	// time to fix the setup.
	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		defer recoverGoroutine("recorder watchdog")
		select {
		case <-stop:
			return
		case <-time.After(3 * time.Second):
		}
		r.srcMu.Lock()
		aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
		r.srcMu.Unlock()
		if aQuiet {
			LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
			AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
		}
		// The mic is only worth a warning when the RADIO is silent too. On CW the
		// mic channel legitimately delivers nothing, and the mixer has already
		// said "recording the radio alone" — repeating it as an alarm made the log
		// read as if something were broken while the recording was going fine.
		if twoSrc && bQuiet && aQuiet {
			LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
		}
	}()

	// Mixer goroutine.
	r.wg.Add(1)
	go func() {
		defer r.wg.Done()
		defer recoverGoroutine("recorder mixer")
		t := time.NewTicker(40 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-stop:
				return
			case <-t.C:
				r.mixTick()
			}
		}
	}()
}

// mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation.
// deadSourceAfter is how long a source may deliver nothing before the recorder
// carries on without it. Long enough not to trip on a scheduling hiccup, short
// enough that almost nothing is lost from the source that IS working.
const deadSourceAfter = 1500 * time.Millisecond

func (r *Recorder) mixTick() {
	r.srcMu.Lock()
	var mixed []int16
	if r.twoSrc {
		// A source that has been silent for a while is treated as ABSENT and the
		// other one is recorded alone.
		//
		// Two sources used to mean min(len(A), len(B)) samples: if one device
		// delivered nothing, NOTHING was recorded, and the drift guard below
		// then threw the live source away a second at a time. That is exactly
		// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
		// path is not running — and the operator got "recording was empty" after
		// a whole QSO. Half a recording is worth having; silence is not.
		now := time.Now()
		// A source is dry when it has been quiet for too long — and a source that
		// has NEVER delivered is measured from when capture started, because it
		// has no last-delivery time of its own.
		//
		// The first version required a source to have spoken at least once. That
		// covers a device that stops, but not the one that actually happens: a
		// DAX Mic channel that is simply switched off never delivers a single
		// sample, so it stayed "not yet dry" forever and took the whole recording
		// down with it. Three empty CW recordings, with the radio audio streaming
		// perfectly the entire time.
		dry := func(last time.Time) bool {
			if last.IsZero() {
				return now.Sub(r.startedAt) > deadSourceAfter
			}
			return now.Sub(last) > deadSourceAfter
		}
		aDry, bDry := dry(r.lastA), dry(r.lastB)
		if bDry && !aDry && len(r.bufA) > 0 {
			if !r.deadB {
				r.deadB = true
				LogSink("recorder: the second audio source is silent — recording the radio alone")
			}
			mixed = make([]int16, len(r.bufA))
			for i, v := range r.bufA {
				mixed[i] = scaleSample(v, r.gainA)
			}
			r.bufA = r.bufA[:0]
			r.bufB = r.bufB[:0]
			r.srcMu.Unlock()
			r.store(mixed)
			return
		}
		if aDry && !bDry && len(r.bufB) > 0 {
			if !r.deadA {
				r.deadA = true
				LogSink("recorder: the radio audio source is silent — recording the microphone alone")
			}
			mixed = make([]int16, len(r.bufB))
			for i, v := range r.bufB {
				mixed[i] = scaleSample(v, r.gainB)
			}
			r.bufA = r.bufA[:0]
			r.bufB = r.bufB[:0]
			r.srcMu.Unlock()
			r.store(mixed)
			return
		}
		n := len(r.bufA)
		if len(r.bufB) < n {
			n = len(r.bufB)
		}
		if n > 0 {
			// Both alive again after one was written off.
			r.deadA, r.deadB = false, false
			mixed = make([]int16, n)
			for i := 0; i < n; i++ {
				mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
			}
			r.bufA = append(r.bufA[:0], r.bufA[n:]...)
			r.bufB = append(r.bufB[:0], r.bufB[n:]...)
		}
		// Drift guard: two sound cards run on their own clocks, so drop the
		// excess to keep them within a second of each other.
		//
		// ONLY while both are actually running. A starved source is not drift: it
		// made this guard throw away the radio audio a second at a time during
		// the grace period, so the opening of every recording was lost even
		// though it had been captured. Waiting costs nothing now — whatever is
		// buffered is written whole the moment the silent source is written off.
		if len(r.bufA) > 0 && len(r.bufB) > 0 {
			if d := len(r.bufA) - len(r.bufB); d > sampleRate {
				r.bufA = append(r.bufA[:0], r.bufA[d:]...)
			} else if d < -sampleRate {
				r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
			}
		}
	} else if len(r.bufA) > 0 {
		mixed = make([]int16, len(r.bufA))
		for i, s := range r.bufA {
			mixed[i] = scaleSample(s, r.gainA)
		}
		r.bufA = r.bufA[:0]
	}
	r.srcMu.Unlock()

	r.store(mixed)
}

// store appends mixed samples to the pre-roll ring and, when a take is running,
// to the take itself.
func (r *Recorder) store(mixed []int16) {
	if len(mixed) == 0 {
		return
	}
	r.mu.Lock()
	r.ring = append(r.ring, mixed...)
	if len(r.ring) > r.prerollSamples {
		r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
	}
	if r.active && !r.paused {
		r.acc = append(r.acc, mixed...)
	}
	r.mu.Unlock()
}

// BeginQSO starts accumulating a recording, seeded with the current pre-roll.
// No-op if already accumulating or not running.
func (r *Recorder) BeginQSO() {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.running || r.active {
		return
	}
	r.acc = append([]int16(nil), r.ring...)
	r.active, r.paused = true, false
}

// RestartQSO begins a fresh accumulation even if one is already active —
// re-seeding from the pre-roll ring. Used when the target QSO changes (a new
// call+freq from a clicked spot or an external app) so the previous take is
// dropped and a new one starts from the pre-roll, rather than continuing to
// accumulate the old contact.
func (r *Recorder) RestartQSO() {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.running {
		return
	}
	r.acc = append([]int16(nil), r.ring...)
	r.active, r.paused = true, false
}

// ResetQSOClock restarts the active accumulation from ZERO — discarding
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
// contain only audio from this moment onward. Used when the contact you entered
// was already in a long QSO and you want to record just your own exchange.
// No-op if not running; if no take is active it begins one (empty).
func (r *Recorder) ResetQSOClock() {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.running {
		return
	}
	r.acc = nil
	r.active = true
}

// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
// new take immediately. Pair with WritePCM to encode/write off the hot path so
// a long recording doesn't delay logging.
func (r *Recorder) TakeQSO() ([]byte, error) {
	r.mu.Lock()
	if !r.active {
		r.mu.Unlock()
		return nil, fmt.Errorf("no active recording")
	}
	samples := r.acc
	r.acc, r.active, r.paused = nil, false, false
	r.mu.Unlock()
	if len(samples) == 0 {
		return nil, fmt.Errorf("recording was empty")
	}
	return int16sToBytes(samples), nil
}

// WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in
// .mp3). Slow for MP3 — call off the logging path.
func WritePCM(path string, data []byte) error {
	if strings.HasSuffix(strings.ToLower(path), ".mp3") {
		return writeMP3(path, data)
	}
	return writeWAV(path, data)
}

// SaveQSO snapshots and writes the recording in one call (synchronous).
func (r *Recorder) SaveQSO(path string) error {
	data, err := r.TakeQSO()
	if err != nil {
		return err
	}
	return WritePCM(path, data)
}

// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
	r.mu.Lock()
	r.acc, r.active, r.paused = nil, false, false
	r.mu.Unlock()
}

// Stop tears down capture+mix.
func (r *Recorder) Stop() {
	r.mu.Lock()
	if !r.running {
		r.mu.Unlock()
		return
	}
	r.running = false
	stop := r.stopCh
	r.stopCh = nil
	r.mu.Unlock()
	close(stop)
	r.wg.Wait()
	r.mu.Lock()
	r.ring, r.acc, r.active, r.paused = nil, nil, false, false
	r.mu.Unlock()
	r.srcMu.Lock()
	r.bufA, r.bufB = nil, nil
	r.srcMu.Unlock()
}

func clampSum(a, b int16) int16 {
	v := int32(a) + int32(b)
	if v > 32767 {
		return 32767
	}
	if v < -32768 {
		return -32768
	}
	return int16(v)
}

func bytesToInt16(b []byte) []int16 {
	out := make([]int16, len(b)/2)
	for i := range out {
		out[i] = int16(binary.LittleEndian.Uint16(b[i*2:]))
	}
	return out
}

func int16sToBytes(s []int16) []byte {
	b := make([]byte, len(s)*2)
	for i, v := range s {
		binary.LittleEndian.PutUint16(b[i*2:], uint16(v))
	}
	return b
}

// PauseQSO freezes the active take without ending it: nothing more is recorded,
// nothing is thrown away, and logging the QSO still writes the file.
//
// This exists for one situation, which is common enough on the bands to be
// worth the state: you are working a station, you have been recording, and they
// ask to hear it. You stop, you play it back to them on the air, and the
// recording is still saved with the QSO afterwards.
func (r *Recorder) PauseQSO() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.active {
		return false
	}
	r.paused = true
	return true
}

// ResumeQSO continues an interrupted take, appending to what is already there.
func (r *Recorder) ResumeQSO() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.active {
		return false
	}
	r.paused = false
	return true
}

// Paused reports whether the active take is frozen.
func (r *Recorder) Paused() bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.active && r.paused
}

// PeekQSO returns what has been captured so far WITHOUT ending the take.
//
// Unlike TakeQSO this keeps the audio, because the take is going to be played
// back and then still saved with the QSO. The copy is deliberate: the caller
// gets bytes it can hold while the recorder keeps appending to its own slice.
func (r *Recorder) PeekQSO() ([]byte, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if !r.active {
		return nil, fmt.Errorf("no active recording")
	}
	if len(r.acc) == 0 {
		return nil, fmt.Errorf("recording is empty")
	}
	return int16sToBytes(append([]int16(nil), r.acc...)), nil
}
