using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using FullCycleProbe;
using Microsoft.ML.OnnxRuntime;
using PortAudioSharp;
using Stream = PortAudioSharp.Stream;

const int SampleRate = WakewordModel.SampleRate;          // 16 000
const int Channels = 1;
const uint BlockFrames = WakewordModel.FrameSamples;      // 1280  (80 ms)
const float Threshold = 0.5f;
const double CooldownSeconds = 1.0;
const double BeepHz = 880.0;
const double BeepSeconds = 0.2;

const int BeepingFrames = 3;       // 3 * 80 ms = 240 ms (covers 200 ms beep + 40 ms drain/reverb margin)
const int RecordingFrames = 63;    // 63 * 80 ms = 5.04 s (rounded up from 5 s to avoid partial-frame handling)
const int RecordBufSamples = RecordingFrames * (int)BlockFrames; // 80640

// .NET-side mirror for any readers that go through Environment.GetEnvironmentVariable,
// AND libc setenv so PortAudio / onnxruntime (which use getenv()) see the values.
Environment.SetEnvironmentVariable("PA_ALSA_PLUGHW", "1");
Libc.setenv("PA_ALSA_PLUGHW", "1", 1);
Environment.SetEnvironmentVariable("ORT_LOGGING_LEVEL", "3");
Libc.setenv("ORT_LOGGING_LEVEL", "3", 1);
// ORT 1.26.0: the env var does NOT suppress early GPU-discovery warnings from device_discovery.cc.
// CreateInstanceWithOptions sets the log level at OrtEnv creation time, before EP discovery runs.
var ortEnvOpts = new EnvironmentCreationOptions { logId = "FullCycleProbe", logLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR };
OrtEnv.CreateInstanceWithOptions(ref ortEnvOpts);

PortAudio.Initialize();
WakewordModel? model = null;
try
{
    int device = FindUsbDevice();
    Console.WriteLine($"Using device {device} ('{PortAudio.GetDeviceInfo(device).name}')");

    Console.WriteLine("Loading WakewordModel...");
    var t0 = DateTime.UtcNow;
    string modelsDir = Path.Combine(AppContext.BaseDirectory, "models");
    model = new WakewordModel(
        Path.Combine(modelsDir, "melspectrogram.onnx"),
        Path.Combine(modelsDir, "embedding_model.onnx"),
        Path.Combine(modelsDir, "alexa.onnx"));
    Console.WriteLine($"Loaded in {(DateTime.UtcNow - t0).TotalSeconds:0.00}s.");

    var queue = new BlockingCollection<short[]>(boundedCapacity: 16);
    using var cts = new CancellationTokenSource();
    Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

    var inParams = new StreamParameters
    {
        device = device,
        channelCount = Channels,
        sampleFormat = SampleFormat.Int16,
        suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency,
        hostApiSpecificStreamInfo = IntPtr.Zero,
    };

    Stream.Callback callback = (IntPtr input, IntPtr _, uint frameCount,
        ref StreamCallbackTimeInfo _2, StreamCallbackFlags status, IntPtr _3) =>
    {
        if (status.HasFlag(StreamCallbackFlags.InputOverflow))
            Console.Error.WriteLine("[status] input overflow");

        if (frameCount != BlockFrames)
        {
            Console.Error.WriteLine($"[status] unexpected callback frameCount={frameCount} (want {BlockFrames})");
            return StreamCallbackResult.Continue;
        }

        var frame = new short[BlockFrames];
        unsafe
        {
            short* src = (short*)input.ToPointer();
            fixed (short* dst = &frame[0])
                Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short));
        }
        if (!queue.TryAdd(frame, 0))
            Console.Error.WriteLine("[status] consumer behind, dropping frame");
        return StreamCallbackResult.Continue;
    };

    using var stream = new Stream(
        inParams, null, SampleRate, BlockFrames, StreamFlags.NoFlag, callback, IntPtr.Zero);
    stream.Start();
    short[] beepBuffer = MakeBeep(BeepHz, BeepSeconds, SampleRate);
    Console.WriteLine("Listening. Say 'alexa'. Ctrl-C to exit.");

    var sw = Stopwatch.StartNew();
    TimeSpan lastTrigger = TimeSpan.FromSeconds(-CooldownSeconds);

    // State machine (single-writer: this consumer thread).
    State state = State.Idle;
    int beepFrames = 0;
    int recordOffset = 0;
    short[] recordBuf = null!;  // assigned on BEEPING→RECORDING transition
    PlaybackDoneFlag? playbackDone = null;

    try
    {
        while (!cts.IsCancellationRequested)
        {
            short[] frame = queue.Take(cts.Token);

            switch (state)
            {
                case State.Idle:
                {
                    float score = model.Predict(frame);
                    var now = sw.Elapsed;
                    if (score >= Threshold && (now - lastTrigger).TotalSeconds >= CooldownSeconds)
                    {
                        Console.WriteLine($"DETECTED alexa score={score:0.000} t={now.TotalSeconds:0.0}s");
                        lastTrigger = now;
                        try
                        {
                            FireAndForgetBeep(device, beepBuffer);
                        }
                        catch (Exception ex)
                        {
                            Console.Error.WriteLine($"[error] beep stream failed to start: {ex.Message}");
                            // Stay in IDLE — no cycle to abort yet.
                            break;
                        }
                        Console.WriteLine("[state] BEEPING");
                        state = State.Beeping;
                        beepFrames = 0;
                    }
                    break;
                }

                case State.Beeping:
                {
                    beepFrames++;
                    if (beepFrames >= BeepingFrames)
                    {
                        Console.WriteLine("[state] RECORDING");
                        state = State.Recording;
                        recordBuf = new short[RecordBufSamples];
                        recordOffset = 0;
                    }
                    break;
                }

                case State.Recording:
                {
                    unsafe
                    {
                        fixed (short* src = &frame[0])
                        fixed (short* dst = &recordBuf[recordOffset])
                            Buffer.MemoryCopy(src, dst, BlockFrames * sizeof(short), BlockFrames * sizeof(short));
                    }
                    recordOffset += (int)BlockFrames;
                    if (recordOffset >= RecordBufSamples)
                    {
                        try
                        {
                            playbackDone = new PlaybackDoneFlag();
                            FireAndForgetPlayback(device, recordBuf, playbackDone);
                        }
                        catch (Exception ex)
                        {
                            Console.Error.WriteLine($"[error] playback stream failed to start: {ex.Message}");
                            // Drop the recording, reset model, return to IDLE.
                            model.Reset();
                            Console.WriteLine("[state] IDLE");
                            state = State.Idle;
                            break;
                        }
                        Console.WriteLine("[state] PLAYBACK");
                        state = State.Playback;
                    }
                    break;
                }

                case State.Playback:
                {
                    // Discard input during playback (gate the detector).
                    if (playbackDone!.Consume())
                    {
                        model.Reset();
                        Console.WriteLine("[state] IDLE");
                        state = State.Idle;
                    }
                    break;
                }
            }
        }
    }
    catch (OperationCanceledException) { /* Ctrl-C path */ }

    stream.Stop();
    Console.WriteLine("\nbye");
}
finally
{
    model?.Dispose();
    PortAudio.Terminate();
}

static int FindUsbDevice()
{
    int count = PortAudio.DeviceCount;
    for (int i = 0; i < count; i++)
    {
        var info = PortAudio.GetDeviceInfo(i);
        if (info.name.ToLowerInvariant().Contains("usb") && info.maxInputChannels >= 1)
            return i;
    }
    Console.Error.WriteLine("USB audio device not found. Devices:");
    for (int i = 0; i < count; i++)
    {
        var info = PortAudio.GetDeviceInfo(i);
        Console.Error.WriteLine(
            $"  [{i}] {info.name} in={info.maxInputChannels} out={info.maxOutputChannels}");
    }
    Environment.Exit(1);
    return -1; // unreachable
}

static short[] MakeBeep(double freqHz, double durationS, int sampleRate)
{
    int n = (int)(sampleRate * durationS);
    var buf = new short[n];
    for (int i = 0; i < n; i++)
    {
        double t = i / (double)sampleRate;
        double v = 0.3 * Math.Sin(2.0 * Math.PI * freqHz * t);
        buf[i] = (short)(v * short.MaxValue);
    }
    return buf;
}

static void FireAndForgetBeep(int device, short[] beepBuffer)
{
    int offset = 0;
    var done = new ManualResetEventSlim(false);

    var outParams = new StreamParameters
    {
        device = device,
        channelCount = 1,
        sampleFormat = SampleFormat.Int16,
        suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency,
        hostApiSpecificStreamInfo = IntPtr.Zero,
    };

    Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount,
        ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) =>
    {
        int remaining = beepBuffer.Length - offset;
        int take = (int)Math.Min(frameCount, (uint)remaining);
        unsafe
        {
            short* dst = (short*)output.ToPointer();
            if (take > 0)
            {
                fixed (short* src = &beepBuffer[offset])
                    Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short));
                offset += take;
            }
            for (int i = take; i < frameCount; i++) dst[i] = 0;
        }
        if (offset >= beepBuffer.Length)
        {
            done.Set();
            return StreamCallbackResult.Complete;
        }
        return StreamCallbackResult.Continue;
    };

    var stream = new Stream(
        null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero);
    try
    {
        stream.Start();
    }
    catch
    {
        stream.Dispose();
        done.Dispose();
        throw;
    }
    Task.Run(() =>
    {
        // 2 s timeout so a failed callback (which PortAudio silently swallows
        // and would leave 'done' unset) eventually releases the stream + handle
        // instead of leaking them for the process lifetime.
        if (!done.Wait(TimeSpan.FromSeconds(2)))
            Console.Error.WriteLine("[error] beep playback timed out — callback may have faulted");
        Thread.Sleep(200);
        stream.Stop();
        stream.Dispose();
        done.Dispose();
    });
}

static void FireAndForgetPlayback(int device, short[] recordBuf, PlaybackDoneFlag doneFlag)
{
    int offset = 0;
    var done = new ManualResetEventSlim(false);

    var outParams = new StreamParameters
    {
        device = device,
        channelCount = 1,
        sampleFormat = SampleFormat.Int16,
        suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowOutputLatency,
        hostApiSpecificStreamInfo = IntPtr.Zero,
    };

    Stream.Callback playCb = (IntPtr _, IntPtr output, uint frameCount,
        ref StreamCallbackTimeInfo _2, StreamCallbackFlags _3, IntPtr _4) =>
    {
        int remaining = recordBuf.Length - offset;
        int take = (int)Math.Min(frameCount, (uint)remaining);
        unsafe
        {
            short* dst = (short*)output.ToPointer();
            if (take > 0)
            {
                fixed (short* src = &recordBuf[offset])
                    Buffer.MemoryCopy(src, dst, take * sizeof(short), take * sizeof(short));
                offset += take;
            }
            for (int i = take; i < frameCount; i++) dst[i] = 0;
        }
        if (offset >= recordBuf.Length)
        {
            done.Set();
            return StreamCallbackResult.Complete;
        }
        return StreamCallbackResult.Continue;
    };

    var stream = new Stream(
        null, outParams, WakewordModel.SampleRate, 1024, StreamFlags.NoFlag, playCb, IntPtr.Zero);
    try
    {
        stream.Start();
    }
    catch
    {
        stream.Dispose();
        done.Dispose();
        throw;
    }
    // Timeout = playback wall-clock + 2 s safety margin. Test 2's beep cleanup uses
    // a flat 2 s because beep is 200 ms; for a 5 s recording the same value would
    // fire mid-playback, Stop the stream, and cut the audio.
    double expectedSeconds = recordBuf.Length / (double)WakewordModel.SampleRate;
    var waitTimeout = TimeSpan.FromSeconds(expectedSeconds + 2.0);
    Task.Run(() =>
    {
        if (!done.Wait(waitTimeout))
            Console.Error.WriteLine("[error] recording playback timed out — callback may have faulted");
        Thread.Sleep(200);
        stream.Stop();
        stream.Dispose();
        done.Dispose();
        doneFlag.Set();  // tell the consumer it's safe to flip PLAYBACK→IDLE
    });
}

enum State { Idle, Beeping, Recording, Playback }

// Volatile flag set by the playback cleanup task and consumed by the consumer thread.
// Wrapped in a tiny class so we can pass it by reference into the helper.
sealed class PlaybackDoneFlag
{
    private volatile bool _set;
    public void Set() => _set = true;
    public bool Consume()
    {
        if (!_set) return false;
        _set = false;
        return true;
    }
}

static class Libc
{
    [DllImport("libc", EntryPoint = "setenv")]
    public static extern int setenv(string name, string value, int overwrite);
}
