// audio/pcmwav.go — tiny WAV ⇄ PCM helpers.
//
// The reference client (cmd/mavenclient) speaks WAV on disk (`arecord -f`
// produces it; `aplay` plays it) and raw PCM on the wire (audio.Audio.Bytes
// is headerless per the audio package). These helpers do the 44-byte
// canonical-PCM-WAV strip/produce dance — a full WAV parser is overkill for
// a single fixed format, and pulling in a third-party WAV library violates
// the "stdlib + modernc only" floor.
//
// Only canonical 16-bit PCM mono WAV (format 1, channel count = 1, bitsPerSample
// = 16) is supported. A non-conforming WAV is rejected with ErrNotCanonicalPCM
// rather than silently mis-routing — the client should produce canonical
// audio (arecord -f cd -r 16000 default; or mavenclient -r) and the helper
// refuses anything else so we don't ship garbage to a model expecting 16k mono.
package audio

import (
	"encoding/binary"
	"errors"
	"fmt"
)

// ErrNotCanonicalPCM — the WAV blob isn't canonical 16-bit mono PCM. Either
// it's a different container (RIFF WAVE with a different format code), wrong
// bit depth, or multi-channel. Refused at the seam rather than resampled —
// resampling on the always-on box wastes cycles, and the client is in the
// better position to do it (it has the platform audio stack).
var ErrNotCanonicalPCM = errors.New("audio: not canonical 16-bit mono PCM WAV")

// wavHeaderSize — the canonical 44-byte PCM WAV header (RIFF + fmt + data,
// each chunk exactly minimum-size). Everything else is an extension we
// don't read and shouldn't accept silently.
const wavHeaderSize = 44

// PCMFromWAV strips a canonical 16-bit mono PCM WAV header and returns the
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
// so the caller surfaces it, not a hidden silent downmix.
func PCMFromWAV(wav []byte) (Format, []byte, error) {
	if len(wav) < wavHeaderSize {
		return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav))
	}
	if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
		return Format{}, nil, fmt.Errorf("%w: missing RIFF/WAVE", ErrNotCanonicalPCM)
	}
	if string(wav[12:16]) != "fmt " {
		return Format{}, nil, fmt.Errorf("%w: missing fmt chunk", ErrNotCanonicalPCM)
	}
	fmtSize := binary.LittleEndian.Uint32(wav[16:20])
	if fmtSize != 16 {
		return Format{}, nil, fmt.Errorf("%w: fmt chunk size %d (not 16)", ErrNotCanonicalPCM, fmtSize)
	}
	audioFormat := binary.LittleEndian.Uint16(wav[20:22])
	if audioFormat != 1 {
		return Format{}, nil, fmt.Errorf("%w: format code %d (not PCM=1)", ErrNotCanonicalPCM, audioFormat)
	}
	channels := int(binary.LittleEndian.Uint16(wav[22:24]))
	sampleRate := int(binary.LittleEndian.Uint32(wav[24:28]))
	bitsPerSample := int(binary.LittleEndian.Uint16(wav[34:36]))
	if channels != 1 || bitsPerSample != 16 {
		return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample)
	}
	// data chunk: the spec mandates it appears right after fmt, but real
	// recorders sometimes append extra chunks (LIST, fact). Find the "data"
	// chunk by scanning; require it within the region we'd expect.
	dataIdx := -1
	for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ {
		if string(wav[i:i+4]) == "data" {
			dataIdx = i
			break
		}
	}
	if dataIdx < 0 {
		return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
	}
	dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
	body := wav[dataIdx+8:]
	if dataSize != 0 && int(dataSize) < len(body) {
		body = body[:dataSize]
	}
	f := Format{
		SampleRate: sampleRate,
		Channels:   1,
		SampleBits: 16,
		Encoding:   "pcm_s16le",
	}
	if !f.IsValid() {
		return Format{}, nil, fmt.Errorf("%w: rate %d (want 16000)", ErrNotCanonicalPCM, sampleRate)
	}
	return f, body, nil
}

// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV
// header so the result can be written to disk and played with `aplay`.
// Used by the reference client to write the TTS reply; not on the wire.
func WAVFromPCM(format Format, pcm []byte) ([]byte, error) {
	hdr, err := WAVHeader(format, len(pcm))
	if err != nil {
		return nil, err
	}
	out := make([]byte, wavHeaderSize+len(pcm))
	copy(out, hdr)
	copy(out[wavHeaderSize:], pcm)
	return out, nil
}

// WAVHeaderSize is the fixed size of the header WAVHeader writes. A caller
// spooling audio to a file reserves this many bytes up front and rewrites them
// once it knows the length.
const WAVHeaderSize = wavHeaderSize

// WAVHeader builds just the 44-byte canonical header for n bytes of PCM. It
// exists so a long recording can be written straight to a file: holding the
// whole meeting in memory to prepend 44 bytes is what the streaming path is
// avoiding.
func WAVHeader(format Format, n int) ([]byte, error) {
	if !format.IsValid() {
		return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
	}
	out := make([]byte, wavHeaderSize)
	// RIFF header
	copy(out[0:4], []byte("RIFF"))
	binary.LittleEndian.PutUint32(out[4:8], uint32(36+n))
	copy(out[8:12], []byte("WAVE"))
	// fmt chunk
	copy(out[12:16], []byte("fmt "))
	binary.LittleEndian.PutUint32(out[16:20], 16) // fmt chunk size
	binary.LittleEndian.PutUint16(out[20:22], 1)  // PCM
	binary.LittleEndian.PutUint16(out[22:24], uint16(format.Channels))
	binary.LittleEndian.PutUint32(out[24:28], uint32(format.SampleRate))
	byteRate := uint32(format.SampleRate) * uint32(format.Channels) * uint32(format.SampleBits) / 8
	binary.LittleEndian.PutUint32(out[28:32], byteRate)
	blockAlign := uint16(format.Channels) * uint16(format.SampleBits) / 8
	binary.LittleEndian.PutUint16(out[32:34], blockAlign)
	binary.LittleEndian.PutUint16(out[34:36], uint16(format.SampleBits))
	// data chunk
	copy(out[36:40], []byte("data"))
	binary.LittleEndian.PutUint32(out[40:44], uint32(n))
	return out, nil
}
