using System.Text;
using DeepDrftContent.Processors;

namespace DeepDrftTests;

/// <summary>
/// Tests for <see cref="AudioProcessor.ProcessWavFileAsync"/> WAV format handling — standard PCM,
/// EXTENSIBLE-PCM, EXTENSIBLE IEEE float, padded 24-in-32, and the default-fallback paths.
/// </summary>
[TestFixture]
public class AudioProcessorTests
{
    private const ushort WaveFormatPcm = 0x0001;
    private const ushort WaveFormatExtensible = 0xFFFE;

    private string _testDir = string.Empty;

    [SetUp]
    public void SetUp()
    {
        _testDir = Path.Combine(Path.GetTempPath(), "AudioProcessorTests", Guid.NewGuid().ToString());
        Directory.CreateDirectory(_testDir);
    }

    [TearDown]
    public void TearDown()
    {
        try { Directory.Delete(_testDir, recursive: true); }
        catch { /* Best-effort cleanup — ignore failures */ }
    }

    [Test]
    public async Task StandardPcm_RoundTripsUnchanged()
    {
        var path = await WriteWavAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm));

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.GreaterThan(0.0));
        Assert.That(audio.Bitrate, Is.GreaterThan(0));
    }

    [Test]
    public async Task ExtensiblePcm_NormalizesToStandardHeader()
    {
        var subFormat = SubFormatGuid(WaveFormatPcm);
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
            subFormatGuid: subFormat, validBitsPerSample: 16);
        var path = await WriteWavAsync(wav);

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.GreaterThan(0.0));
        Assert.That(audio.Bitrate, Is.GreaterThan(0));
        Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm), "Stored buffer must be standard PCM");
    }

    [Test]
    public async Task ExtensibleIeeeFloat_AcceptedAndConverted()
    {
        // Two stereo frames of 32-bit float samples (range [-1.0, 1.0]).
        var samples = FloatBytes(0.5f, -0.5f, 1.0f, -1.0f);
        var subFormat = SubFormatGuid(0x0003); // WAVE_FORMAT_IEEE_FLOAT
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
            sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 32);
        var path = await WriteWavAsync(wav);

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(16 + 8), "Float must convert to 24-bit PCM");
        Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
        // 4 float samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes after the 44-byte header.
        Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
        // Verify the converted sample values: (int)(sample * 8388607.0), clamped, little-endian 3 bytes.
        // 0.5f  → 4194303  = 0x3FFFFF → FF FF 3F
        // -0.5f → -4194303 = 0xFFC00001 → 24-bit LE: 01 00 C0
        // 1.0f  → 8388607  = 0x7FFFFF → FF FF 7F
        // -1.0f → -8388607 = 0xFF800001 → 24-bit LE: 01 00 80
        var expectedData = new byte[] { 0xFF, 0xFF, 0x3F, 0x01, 0x00, 0xC0, 0xFF, 0xFF, 0x7F, 0x01, 0x00, 0x80 };
        var actualData = audio.Buffer[44..];
        Assert.That(actualData, Is.EqualTo(expectedData), "Float samples must be converted to 24-bit PCM correctly");
    }

    [Test]
    public async Task ExtensiblePadded24in32_AcceptedAndRepacked()
    {
        // Two stereo frames; each sample is a 24-bit value stored in a 32-bit little-endian container.
        var samples = Padded24In32Bytes(0x123456, unchecked((int)0xFFEDCBA9), 0x000001, unchecked((int)0xFF800000));
        var subFormat = SubFormatGuid(WaveFormatPcm);
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
            sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 24);
        var path = await WriteWavAsync(wav);

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(24), "Padded container must repack to 24-bit");
        Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
        // 4 container samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes.
        Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
        // Verify the repacked sample values: lowest 3 bytes of each 4-byte little-endian container.
        // 0x123456     → LE 4 bytes: 56 34 12 00 → keep 3: 56 34 12
        // 0xFFEDCBA9   → LE 4 bytes: A9 CB ED FF → keep 3: A9 CB ED
        // 0x000001     → LE 4 bytes: 01 00 00 00 → keep 3: 01 00 00
        // 0xFF800000   → LE 4 bytes: 00 00 80 FF → keep 3: 00 00 80
        var expectedData = new byte[] { 0x56, 0x34, 0x12, 0xA9, 0xCB, 0xED, 0x01, 0x00, 0x00, 0x00, 0x00, 0x80 };
        var actualData = audio.Buffer[44..];
        Assert.That(actualData, Is.EqualTo(expectedData), "Padded 24-in-32 samples must strip the padding byte correctly");
    }

    [Test]
    public async Task ExtensibleUnsupportedSubFormat_FallsBackToDefaults()
    {
        var subFormat = SubFormatGuid(0x0005); // WAVE_FORMAT_DOLBY_AC3 — neither PCM nor float
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
            subFormatGuid: subFormat, validBitsPerSample: 16);
        var path = await WriteWavAsync(wav);

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unsupported SubFormat must fall back to default metadata");
    }

    [Test]
    public async Task ExtensibleFmtTooSmall_FallsBackToDefaults()
    {
        // audioFormat=EXTENSIBLE but fmt chunk declares 16 bytes — too small for the extension.
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
            forceFmtChunkSize: 16);
        var path = await WriteWavAsync(wav);

        var audio = await new AudioProcessor().ProcessWavFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.EqualTo(180.0), "Undersized EXTENSIBLE fmt chunk must fall back to default metadata");
    }

    [Test]
    public void TryExtractPcm_FloatWav_ReturnsNull()
    {
        var subFormat = SubFormatGuid(0x0003);
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
            subFormatGuid: subFormat, validBitsPerSample: 32);
        var result = new AudioProcessor().TryExtractPcm(wav);
        Assert.That(result, Is.Null);
    }

    [Test]
    public void TryExtractPcm_Padded24in32_ReturnsNull()
    {
        var subFormat = SubFormatGuid(WaveFormatPcm);
        var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
            subFormatGuid: subFormat, validBitsPerSample: 24);
        var result = new AudioProcessor().TryExtractPcm(wav);
        Assert.That(result, Is.Null);
    }

    // -- MP3 ------------------------------------------------------------------------------------

    [Test]
    public async Task Mp3_CbrMetadata_ParsedCorrectly()
    {
        // BuildMinimalMp3: bitrateKbps=128, sampleRate=44100, stereo=true, no Xing tag, no ID3 tag.
        // frameSize = floor(144 * 128000 / 44100) = 417 bytes; bufferSize = max(417, 48) = 417.
        // CBR duration = (bufferLength - frameStart) / (bitrateKbps * 125) = 417 / 16000 ≈ 0.0261 s.
        const int bitrateKbps = 128;
        const int sampleRate = 44100;
        var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate); // 417
        var bufferSize = Math.Max(frameSize, 4 + 32 + 12); // max(417, 48) = 417
        var expectedDuration = (double)bufferSize / (bitrateKbps * 125); // frameStart = 0 (no ID3)

        var path = await WriteAudioAsync(BuildMinimalMp3(bitrateKbps: bitrateKbps, sampleRate: sampleRate, stereo: true), ".mp3");

        var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Extension, Is.EqualTo(".mp3"));
        Assert.That(audio.Bitrate, Is.EqualTo(bitrateKbps));
        Assert.That(audio.Duration, Is.EqualTo(expectedDuration).Within(0.01));
    }

    [Test]
    public async Task Mp3_VbrWithXingHeader_DurationFromXing()
    {
        const int frameCount = 1000;
        const int sampleRate = 44100;
        var path = await WriteAudioAsync(
            BuildMinimalMp3(bitrateKbps: 128, sampleRate: sampleRate, stereo: true, addXingHeader: true, xingFrameCount: frameCount),
            ".mp3");

        var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);

        Assert.That(audio, Is.Not.Null);
        var expected = (double)frameCount * 1152 / sampleRate;
        Assert.That(audio!.Duration, Is.EqualTo(expected).Within(0.001), "VBR duration must come from the Xing frame count");
    }

    [Test]
    public async Task Mp3_InvalidFile_FallsBackToDefaults()
    {
        // A standard PCM WAV has no MPEG frame sync — the parser must fall back to defaults.
        var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".mp3");

        var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable MP3 must fall back to default duration");
    }

    // -- FLAC -----------------------------------------------------------------------------------

    [Test]
    public async Task Flac_StreaminfoMetadata_ParsedCorrectly()
    {
        var path = await WriteAudioAsync(
            BuildMinimalFlac(sampleRate: 44100, channels: 2, bitsPerSample: 24, totalSamples: 441000),
            ".flac");

        var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Extension, Is.EqualTo(".flac"));
        Assert.That(audio.Duration, Is.EqualTo(10.0).Within(0.01), "441000 samples / 44100 Hz = 10 s");
        Assert.That(audio.Bitrate, Is.GreaterThan(0));
    }

    [Test]
    public async Task Flac_InvalidFile_FallsBackToDefaults()
    {
        // A standard PCM WAV lacks the fLaC magic — the parser must fall back to defaults.
        var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".flac");

        var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);

        Assert.That(audio, Is.Not.Null);
        Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable FLAC must fall back to default duration");
    }

    // -- Router ---------------------------------------------------------------------------------

    [Test]
    public async Task Router_DispatchesByExtension()
    {
        var router = new AudioProcessorRouter(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());

        var wavPath = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".wav");
        var mp3Path = await WriteAudioAsync(BuildMinimalMp3(), ".mp3");
        var flacPath = await WriteAudioAsync(BuildMinimalFlac(), ".flac");

        var wav = await router.ProcessAudioFileAsync(wavPath);
        var mp3 = await router.ProcessAudioFileAsync(mp3Path);
        var flac = await router.ProcessAudioFileAsync(flacPath);

        Assert.That(wav, Is.Not.Null);
        Assert.That(wav!.Extension, Is.EqualTo(".wav"));
        Assert.That(mp3, Is.Not.Null);
        Assert.That(mp3!.Extension, Is.EqualTo(".mp3"));
        Assert.That(flac, Is.Not.Null);
        Assert.That(flac!.Extension, Is.EqualTo(".flac"));
    }

    // -- helpers --------------------------------------------------------------------------------

    /// <summary>
    /// Synthesises a minimal valid MPEG1 Layer III CBR MP3 buffer: one frame header plus enough body
    /// bytes for the frame, with an optional Xing VBR header in the side-information region. The body
    /// is zero-filled (silence) — only the header and any Xing tag are meaningful to the parser.
    /// </summary>
    private static byte[] BuildMinimalMp3(
        int bitrateKbps = 128,
        int sampleRate = 44100,
        bool stereo = true,
        bool addXingHeader = false,
        int xingFrameCount = 0)
    {
        // MPEG1 Layer III bitrate index for the kbps table [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320].
        var bitrateIndex = Array.IndexOf(
            new[] { 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 }, bitrateKbps);
        var sampleRateIndex = Array.IndexOf(new[] { 44100, 48000, 32000 }, sampleRate);

        // Byte 1: 1111 1011 = sync(111) version(11=MPEG1) layer(01=Layer III) protection(1=no CRC).
        const byte b1 = 0xFB;
        // Byte 2: bitrate index (4) | sample-rate index (2) | padding (1=0) | private (1=0).
        var b2 = (byte)((bitrateIndex << 4) | (sampleRateIndex << 2));
        // Byte 3: channel mode (2) | mode ext (2) | copyright (1) | original (1) | emphasis (2).
        // 00 = stereo, 11 = mono.
        var b3 = (byte)(stereo ? 0x00 : 0xC0);

        var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate);

        // Side-info size for MPEG1: 32 bytes stereo, 17 bytes mono. Xing tag sits just past it.
        var sideInfoSize = stereo ? 32 : 17;
        var bufferSize = Math.Max(frameSize, 4 + sideInfoSize + 12);
        var buffer = new byte[bufferSize];

        buffer[0] = 0xFF;
        buffer[1] = b1;
        buffer[2] = b2;
        buffer[3] = b3;

        if (addXingHeader)
        {
            var tagPos = 4 + sideInfoSize;
            buffer[tagPos] = (byte)'X';
            buffer[tagPos + 1] = (byte)'i';
            buffer[tagPos + 2] = (byte)'n';
            buffer[tagPos + 3] = (byte)'g';
            // Flags: bit 0 set = frame count present.
            buffer[tagPos + 7] = 0x01;
            // Frame count: big-endian uint32 at tag offset 8.
            buffer[tagPos + 8] = (byte)((xingFrameCount >> 24) & 0xFF);
            buffer[tagPos + 9] = (byte)((xingFrameCount >> 16) & 0xFF);
            buffer[tagPos + 10] = (byte)((xingFrameCount >> 8) & 0xFF);
            buffer[tagPos + 11] = (byte)(xingFrameCount & 0xFF);
        }

        return buffer;
    }

    /// <summary>
    /// Synthesises a minimal FLAC buffer: <c>fLaC</c> magic, a STREAMINFO metadata block header, a
    /// 34-byte STREAMINFO body carrying the sample rate, channel count, bit depth, and total sample
    /// count, plus <paramref name="audioDataBytes"/> of trailing zero bytes standing in for the encoded
    /// audio frames. The trailing bytes only affect the average-bitrate computation (file size); the
    /// parser ignores their content. Other STREAMINFO fields are left zero — the parser ignores them.
    /// </summary>
    private static byte[] BuildMinimalFlac(
        int sampleRate = 44100,
        int channels = 2,
        int bitsPerSample = 24,
        long totalSamples = 441000,
        int audioDataBytes = 256_000)
    {
        var buffer = new byte[4 + 4 + 34 + audioDataBytes];

        // Magic.
        buffer[0] = (byte)'f';
        buffer[1] = (byte)'L';
        buffer[2] = (byte)'a';
        buffer[3] = (byte)'C';

        // Metadata block header: byte 0 = is_last(1) | block type(7=0 STREAMINFO); bytes 1-3 = length 34.
        buffer[4] = 0x80; // last block, type 0
        buffer[5] = 0x00;
        buffer[6] = 0x00;
        buffer[7] = 34;

        // STREAMINFO body begins at offset 8. We only set the bit-packed fields the parser reads:
        //   bytes 10-12 + top nibble of 13: sample rate (20 bits)
        //   bits 3-1 of byte 12: channels - 1
        //   bit 0 of byte 12 + top nibble of 13: bits per sample - 1
        //   low nibble of byte 13 + bytes 14-17: total samples (36 bits)
        var d = 8;

        // 20-bit sample rate split across bytes 10, 11, and the top nibble of 12.
        buffer[d + 10] = (byte)((sampleRate >> 12) & 0xFF);
        buffer[d + 11] = (byte)((sampleRate >> 4) & 0xFF);

        var bps = bitsPerSample - 1; // 5 bits: bit 0 of byte 12 + top 4 bits of byte 13
        var ch = channels - 1;       // 3 bits: bits 3-1 of byte 12

        // Byte 12: [sampleRate low nibble (4)] [channels-1 (3)] [bps-1 high bit (1)].
        buffer[d + 12] = (byte)(((sampleRate & 0x0F) << 4) | ((ch & 0x07) << 1) | ((bps >> 4) & 0x01));

        // Byte 13: [bps-1 low 4 bits (4)] [total samples bits 35-32 (4)].
        buffer[d + 13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));

        // Bytes 14-17: total samples low 32 bits, big-endian.
        buffer[d + 14] = (byte)((totalSamples >> 24) & 0xFF);
        buffer[d + 15] = (byte)((totalSamples >> 16) & 0xFF);
        buffer[d + 16] = (byte)((totalSamples >> 8) & 0xFF);
        buffer[d + 17] = (byte)(totalSamples & 0xFF);

        return buffer;
    }

    private async Task<string> WriteAudioAsync(byte[] bytes, string extension)
    {
        var path = Path.Combine(_testDir, Guid.NewGuid() + extension);
        await File.WriteAllBytesAsync(path, bytes);
        return path;
    }

    /// <summary>
    /// Synthesises a minimal valid WAV buffer. For EXTENSIBLE (audioFormat=0xFFFE) the fmt chunk is
    /// 40 bytes and includes cbSize, wValidBitsPerSample, channel mask, and the SubFormat GUID. For
    /// standard PCM (audioFormat=1) the fmt chunk is 16 bytes. When <paramref name="sampleData"/> is
    /// null a small block of silence sized to the block alignment is used.
    /// </summary>
    private static byte[] BuildMinimalWav(
        int channels,
        int sampleRate,
        int bitsPerSample,
        ushort audioFormat,
        byte[]? sampleData = null,
        byte[]? subFormatGuid = null,
        ushort validBitsPerSample = 0,
        uint? forceFmtChunkSize = null)
    {
        var isExtensible = audioFormat == WaveFormatExtensible;
        var fmtChunkSize = forceFmtChunkSize ?? (isExtensible ? 40u : 16u);

        var blockAlign = (ushort)(channels * (bitsPerSample / 8));
        var byteRate = (uint)(sampleRate * blockAlign);
        var data = sampleData ?? new byte[blockAlign * 2];

        using var ms = new MemoryStream();
        using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);

        w.Write(Encoding.ASCII.GetBytes("RIFF"));
        w.Write((uint)(36 + fmtChunkSize - 16 + data.Length)); // riff size adjusted for fmt extension
        w.Write(Encoding.ASCII.GetBytes("WAVE"));

        w.Write(Encoding.ASCII.GetBytes("fmt "));
        w.Write(fmtChunkSize);
        w.Write(audioFormat);
        w.Write((ushort)channels);
        w.Write((uint)sampleRate);
        w.Write(byteRate);
        w.Write(blockAlign);
        w.Write((ushort)bitsPerSample);

        // Only emit the EXTENSIBLE extension when the declared fmt size actually allows for it. A
        // forced-small size (fmt=16) leaves audioFormat=EXTENSIBLE but no extension, exercising the
        // "fmt too small" fallback.
        if (fmtChunkSize >= 40)
        {
            w.Write((ushort)22); // cbSize
            w.Write(validBitsPerSample);
            w.Write((uint)0); // channel mask
            w.Write(subFormatGuid ?? SubFormatGuid(WaveFormatPcm));
        }

        w.Write(Encoding.ASCII.GetBytes("data"));
        w.Write((uint)data.Length);
        w.Write(data);

        w.Flush();
        return ms.ToArray();
    }

    /// <summary>Builds a 16-byte SubFormat GUID whose leading 2 bytes are the format tag.</summary>
    private static byte[] SubFormatGuid(ushort formatTag)
    {
        var guid = new byte[16];
        guid[0] = (byte)(formatTag & 0xFF);
        guid[1] = (byte)((formatTag >> 8) & 0xFF);
        // Remaining 14 bytes are the fixed KSDATAFORMAT suffix; their value is irrelevant to parsing.
        return guid;
    }

    private static byte[] FloatBytes(params float[] samples)
    {
        var bytes = new byte[samples.Length * 4];
        for (int i = 0; i < samples.Length; i++)
        {
            BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
        }
        return bytes;
    }

    /// <summary>Packs each 24-bit sample value into a 32-bit little-endian container.</summary>
    private static byte[] Padded24In32Bytes(params int[] samples)
    {
        var bytes = new byte[samples.Length * 4];
        for (int i = 0; i < samples.Length; i++)
        {
            BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
        }
        return bytes;
    }

    private async Task<string> WriteWavAsync(byte[] wav)
    {
        var path = Path.Combine(_testDir, Guid.NewGuid() + ".wav");
        await File.WriteAllBytesAsync(path, wav);
        return path;
    }

    private static ushort ReadFmtAudioFormat(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 20);

    private static ushort ReadFmtBitsPerSample(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 34);
}
