using System.IO;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;

namespace TeamsISO.Engine.Pipeline;

/// <summary>
/// Concrete <see cref="IRecorderSink"/> that writes the processed BGRA stream to
/// disk along with a sidecar JSON manifest and an <c>ffmpeg.cmd</c> conversion
/// script. We deliberately do NOT depend on Media Foundation, libx264, or any
/// other native encoder for v1: the engine stays self-contained, and operators
/// who want H.264 .mkv output can run the generated <c>ffmpeg.cmd</c> after the
/// recording finishes (FFmpeg on PATH is a common operator install).
///
/// Files produced under <c>&lt;outputDir&gt;/&lt;sanitized-display-name&gt;/</c>:
///   <list type="bullet">
///     <item><c>video.bgra</c> — raw concatenated frames at <c>width*height*4</c> bytes each.</item>
///     <item><c>manifest.json</c> — width, height, fps, format, started/ended timestamps,
///           frame count. Lets a post-processor reconstruct timing without parsing the .bgra.</item>
///     <item><c>convert.cmd</c> — one-liner that pipes the .bgra into ffmpeg to produce
///           a final <c>output.mkv</c> at H.264. Operators just double-click.</item>
///   </list>
///
/// Disk pressure: BGRA at 1080p60 is ~500 MB/s, at 720p30 it's ~88 MB/s. A 30-min
/// recording at the default 720p30 takes ~150 GB. Operators should record at the
/// lowest acceptable resolution / framerate, or enable recording only on the
/// participants they intend to keep. A future <c>MediaFoundationRecorderSink</c>
/// would compress in-process and reduce this 10× — see _NEXT.md.
///
/// Threading: writes are serialized through a single bounded channel so the
/// pipeline's processor thread never blocks on disk I/O. If the disk can't keep
/// up, frames are dropped (and the manifest's drop counter increments) so the
/// live ISO output is never delayed.
/// </summary>
public sealed class RawBgraRecorderSink : IRecorderSink
{
    private readonly ILogger<RawBgraRecorderSink>? _logger;
    private readonly Channel<ProcessedFrame> _queue;
    private CancellationTokenSource? _writerCts;
    private Task? _writerTask;
    private FileStream? _videoStream;
    private string? _recordingDir;
    private int _width;
    private int _height;
    private double _fps;
    private long _framesWritten;
    private long _framesDropped;
    private DateTimeOffset _startedAt;
    private string _displayName = string.Empty;

    // Operator-dropped markers, recorded in wall-clock order. We accumulate them
    // in memory during the recording and write to manifest.json on Close. Lock
    // is required because AddMarker can be called from the UI thread while the
    // writer task drains video frames in the background.
    private readonly List<MarkerEntry> _markers = new();
    private readonly object _markersGate = new();
    private sealed record MarkerEntry(double OffsetMs, string Label);

    public bool IsRecording { get; private set; }

    public RawBgraRecorderSink(ILogger<RawBgraRecorderSink>? logger = null)
    {
        _logger = logger;
        // Bounded queue with DropOldest: if the disk falls behind, lose the
        // oldest frames and keep recording — better than blocking the pipeline.
        // 240 frames ≈ 4 seconds @ 60 fps; gives us a buffer for transient
        // disk hiccups without unbounded RAM growth on a stuck volume.
        _queue = Channel.CreateBounded<ProcessedFrame>(new BoundedChannelOptions(240)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
            SingleReader = true,
            SingleWriter = false, // pipeline thread + close() can both write
        });
    }

    public void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps)
    {
        if (IsRecording) return;

        _width = width;
        _height = height;
        _fps = fps;
        _startedAt = DateTimeOffset.Now;
        _framesWritten = 0;
        _framesDropped = 0;
        _displayName = participantDisplayName;

        var safeName = SanitizeForFileName(participantDisplayName);
        _recordingDir = Path.Combine(outputDirectory, safeName);
        Directory.CreateDirectory(_recordingDir);

        var videoPath = Path.Combine(_recordingDir, "video.bgra");
        // Pre-allocate via FileStream with sequential-scan hint; the writer thread
        // appends. Buffer size tuned to one full frame so writes are aligned and
        // we don't fragment on common allocators.
        var bufferSize = Math.Max(width * height * 4, 64 * 1024);
        _videoStream = new FileStream(
            videoPath,
            FileMode.Create, FileAccess.Write, FileShare.Read,
            bufferSize: bufferSize,
            FileOptions.SequentialScan);

        _writerCts = new CancellationTokenSource();
        _writerTask = Task.Run(() => WriterLoopAsync(_writerCts.Token));

        IsRecording = true;
        _logger?.LogInformation(
            "Recorder open: {Path} ({W}x{H}@{Fps:F2}fps)",
            _recordingDir, width, height, fps);
    }

    public bool WriteFrame(ProcessedFrame frame)
    {
        if (!IsRecording) return false;
        if (_queue.Writer.TryWrite(frame)) return true;
        Interlocked.Increment(ref _framesDropped);
        return false;
    }

    public void AddMarker(string label)
    {
        if (!IsRecording) return;
        var offsetMs = (DateTimeOffset.Now - _startedAt).TotalMilliseconds;
        lock (_markersGate)
        {
            _markers.Add(new MarkerEntry(offsetMs, label ?? string.Empty));
        }
    }

    public void Close()
    {
        if (!IsRecording) return;
        IsRecording = false;

        // Mark the writer queue complete; the writer task drains remaining frames.
        _queue.Writer.TryComplete();
        try { _writerTask?.Wait(TimeSpan.FromSeconds(5)); }
        catch { /* defensive: writer task may have already faulted */ }
        _writerCts?.Cancel();
        _writerCts?.Dispose();
        _writerCts = null;
        _writerTask = null;

        try { _videoStream?.Flush(); _videoStream?.Dispose(); }
        catch { /* defensive */ }
        _videoStream = null;

        if (_recordingDir is not null)
        {
            TryWriteManifest();
            TryWriteFfmpegScript();
        }

        _logger?.LogInformation(
            "Recorder closed: {Frames} written, {Dropped} dropped to {Path}",
            _framesWritten, _framesDropped, _recordingDir);
    }

    public async ValueTask DisposeAsync()
    {
        Close();
        await Task.CompletedTask;
    }

    private async Task WriterLoopAsync(CancellationToken ct)
    {
        try
        {
            await foreach (var frame in _queue.Reader.ReadAllAsync(ct))
            {
                if (_videoStream is null) break;
                try
                {
                    // ProcessedFrame.Pixels is a ReadOnlyMemory<byte>; FileStream.Write
                    // accepts ReadOnlySpan<byte> so we can write without an extra copy.
                    _videoStream.Write(frame.Pixels.Span);
                    Interlocked.Increment(ref _framesWritten);
                }
                catch (Exception ex)
                {
                    _logger?.LogWarning(ex, "Recorder write failed; will count as dropped.");
                    Interlocked.Increment(ref _framesDropped);
                }
            }
        }
        catch (OperationCanceledException) { /* expected */ }
    }

    private void TryWriteManifest()
    {
        try
        {
            MarkerEntry[] markersSnapshot;
            lock (_markersGate) { markersSnapshot = _markers.ToArray(); }

            var manifest = new
            {
                schema = "teamsiso-recorder/v1",
                participantDisplayName = _displayName,
                width = _width,
                height = _height,
                fps = _fps,
                pixelFormat = "BGRA",
                bytesPerFrame = _width * _height * 4,
                startedAt = _startedAt.ToString("o"),
                endedAt = DateTimeOffset.Now.ToString("o"),
                framesWritten = Interlocked.Read(ref _framesWritten),
                framesDropped = Interlocked.Read(ref _framesDropped),
                markers = markersSnapshot.Select(m => new { offsetMs = m.OffsetMs, label = m.Label }).ToArray(),
            };
            var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(Path.Combine(_recordingDir!, "manifest.json"), json);
        }
        catch (Exception ex)
        {
            _logger?.LogWarning(ex, "Failed to write recorder manifest.");
        }
    }

    private void TryWriteFfmpegScript()
    {
        try
        {
            // Operators rarely have FFmpeg's exact rawvideo-to-MKV recipe memorized;
            // ship a one-liner so they can convert by double-clicking. Quotes around
            // the input filename so paths with spaces still work; -y auto-overwrites
            // an existing output.mkv if the operator runs the script twice.
            var script =
                "@echo off\r\n" +
                "REM Convert raw BGRA recording to H.264 MKV. Requires FFmpeg on PATH (download from ffmpeg.org).\r\n" +
                $"ffmpeg -y -f rawvideo -pix_fmt bgra -s {_width}x{_height} -r {_fps:F2} -i video.bgra " +
                "-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p output.mkv\r\n" +
                "if errorlevel 1 (echo FFmpeg failed. Is it installed and on PATH?) else (echo Wrote output.mkv)\r\n" +
                "pause\r\n";
            File.WriteAllText(Path.Combine(_recordingDir!, "convert.cmd"), script);
        }
        catch (Exception ex)
        {
            _logger?.LogWarning(ex, "Failed to write recorder convert.cmd.");
        }
    }

    /// <summary>
    /// Strip characters that would make the display name an invalid Windows
    /// filename (or path-traversal vector). Empty or all-stripped names fall
    /// back to "participant" so we always have a usable directory name.
    /// </summary>
    private static string SanitizeForFileName(string name)
    {
        if (string.IsNullOrWhiteSpace(name)) return "participant";
        var invalid = Path.GetInvalidFileNameChars();
        var clean = new string(name.Where(c => !invalid.Contains(c) && c != '.').ToArray()).Trim();
        return string.IsNullOrEmpty(clean) ? "participant" : clean;
    }
}
