using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
using IsoHealthStats = TeamsISO.Engine.Domain.IsoHealthStats;

namespace TeamsISO.App.ViewModels;

/// <summary>
/// Per-row view model for a participant in the participant list.
/// Wraps a domain <see cref="Participant"/> and exposes ISO toggle and naming commands.
/// </summary>
public sealed class ParticipantViewModel : ObservableObject
{
    private readonly IIsoController _controller;
    private readonly ToastViewModel? _toast;
    private Participant _participant;
    private bool _isEnabled;
    private bool _isProcessing;
    private string _customName;

    /// <summary>Thumbnail dimensions. 16:9 ratio matches the typical Teams output aspect.</summary>
    private const int ThumbnailWidth = 160;
    private const int ThumbnailHeight = 90;

    /// <summary>
    /// Live preview of the most recent processed frame, scaled to <see cref="ThumbnailWidth"/>×
    /// <see cref="ThumbnailHeight"/>. Updated by <see cref="UpdateThumbnail"/> on the UI
    /// thread, called from MainViewModel's stats tick. Null until the first frame arrives.
    /// </summary>
    public WriteableBitmap? Thumbnail
    {
        get => _thumbnail;
        private set
        {
            if (SetField(ref _thumbnail, value))
                OnPropertyChanged(nameof(HasThumbnail));
        }
    }
    private WriteableBitmap? _thumbnail;

    /// <summary>True when <see cref="Thumbnail"/> is non-null. Bound to Visibility in XAML.</summary>
    public bool HasThumbnail => _thumbnail is not null;

    /// <summary>
    /// True when this participant is currently the loudest among the live
    /// set — set by MainViewModel at the 1Hz stats tick. Bound to a cyan
    /// border accent on the DataGrid row so operators can spot who's
    /// speaking without watching every VU bar individually.
    /// </summary>
    public bool IsActiveSpeaker
    {
        get => _isActiveSpeaker;
        internal set => SetField(ref _isActiveSpeaker, value);
    }
    private bool _isActiveSpeaker;

    public ParticipantViewModel(IIsoController controller, Participant participant, ToastViewModel? toast = null)
    {
        _controller = controller;
        _toast = toast;
        _participant = participant;
        _customName = string.Empty;
        ToggleIsoCommand = new AsyncRelayCommand(ToggleIsoAsync, () => !_isProcessing);
        CopySourceNameCommand = new RelayCommand(() =>
        {
            try
            {
                var src = _participant.CurrentSource?.FullName;
                if (!string.IsNullOrEmpty(src))
                    System.Windows.Clipboard.SetText(src);
            }
            catch
            {
                // Clipboard occasionally errors when something else has it locked;
                // best-effort, no user-visible failure.
            }
        });

        OpenPreviewCommand = new RelayCommand(() =>
        {
            // Non-modal — operator can open multiple previews at once.
            // Owner is the main window so the preview centers nicely and
            // closes cleanly when the host exits.
            var preview = new PreviewWindow(_controller, Id, DisplayName);
            preview.Show();
        });

        RestartIsoCommand = new AsyncRelayCommand(RestartIsoAsync,
            () => _isEnabled && !_isProcessing);

        SaveSnapshotCommand = new RelayCommand(SaveSnapshot, () => _isEnabled);
    }

    /// <summary>
    /// Encode the most recent <see cref="IsoPipeline.LatestProcessedFrame"/>
    /// as a PNG and write it to <c>%USERPROFILE%\Pictures\TeamsISO\</c>. Used
    /// by the participants' context menu for grabbing a stillframe — useful
    /// for highlight reels, social posts, bug reports. Best-effort: a no-op
    /// + warn-toast if no frame is currently available (pipeline just spun
    /// up, or recording isn't enabled). Filename includes participant name
    /// + timestamp so back-to-back snapshots don't collide.
    /// </summary>
    private void SaveSnapshot()
    {
        try
        {
            var frame = _controller.GetLatestProcessedFrame(Id);
            if (frame is null || frame.Pixels.IsEmpty)
            {
                _toast?.Warn("No frame available yet — try again in a few seconds");
                return;
            }

            var dir = System.IO.Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
                "TeamsISO");
            System.IO.Directory.CreateDirectory(dir);

            var safeName = string.Join("_", DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
            if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
            var path = System.IO.Path.Combine(dir,
                $"{safeName}_{DateTimeOffset.Now:yyyyMMdd_HHmmss}.png");

            // ProcessedFrame is BGRA32, top-down. WriteableBitmap with
            // Bgra32 pixel format takes the bytes verbatim.
            var stride = frame.Width * 4;
            var bmp = new System.Windows.Media.Imaging.WriteableBitmap(
                frame.Width, frame.Height,
                96, 96,
                System.Windows.Media.PixelFormats.Bgra32, null);
            bmp.WritePixels(
                new System.Windows.Int32Rect(0, 0, frame.Width, frame.Height),
                frame.Pixels.ToArray(), stride, 0);

            using var fs = new System.IO.FileStream(path, System.IO.FileMode.Create);
            var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
            encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bmp));
            encoder.Save(fs);

            _toast?.Show($"Saved snapshot: {System.IO.Path.GetFileName(path)}");
        }
        catch (Exception ex)
        {
            _toast?.Warn($"Snapshot failed: {ex.Message}");
        }
    }

    /// <summary>
    /// Disable + re-enable this pipeline. Brief delay between for the engine
    /// to fully tear down before we ask for a fresh sender. The processing
    /// flag suppresses the toggle button + restart action while in flight.
    /// </summary>
    private async Task RestartIsoAsync()
    {
        if (!IsEnabled) return;
        IsProcessing = true;
        try
        {
            await _controller.DisableIsoAsync(Id, CancellationToken.None);
            // Short delay so any in-flight NDI sender disposal completes before
            // we ask CreateSender for the same name. Empirically 250ms is plenty.
            await Task.Delay(250);
            var resolvedName = string.IsNullOrWhiteSpace(_customName)
                ? Services.OutputNameTemplate.Render(
                    Services.OutputNameTemplate.Get(),
                    Id,
                    DisplayName)
                : _customName;
            bool? recordOverride = _recordToDisk ? null : false;
            await _controller.EnableIsoAsync(Id, resolvedName, recordOverride, CancellationToken.None);
            // IsEnabled is already true (we never set it false); re-fire the
            // change notification so any UI bindings sensitive to a transition
            // observe the restart.
            OnPropertyChanged(nameof(IsEnabled));
        }
        finally
        {
            IsProcessing = false;
        }
    }

    /// <summary>
    /// Refresh the preview thumbnail from the engine's most recent processed frame.
    /// Must be called on the UI thread (MainViewModel's stats DispatcherTimer is fine).
    /// Allocates the WriteableBitmap lazily on the first call so we don't pay for it
    /// on participants that never have an ISO enabled. Skips work if the engine has
    /// no frame yet (no pipeline, or pipeline still warming up).
    /// </summary>
    public void UpdateThumbnail(ProcessedFrame? frame)
    {
        if (frame is null || frame.Pixels.IsEmpty)
        {
            // Don't clear a previously-rendered thumbnail on transient null reads —
            // a brief gap between frames shouldn't visibly blank the preview. The
            // Thumbnail is only set to null when the pipeline genuinely stops, which
            // we observe by IsEnabled flipping false elsewhere.
            return;
        }

        // Defense in depth: if the engine ever hands us a frame whose pixel buffer
        // doesn't match the declared dimensions (would imply an engine bug), don't
        // crash the UI on IndexOutOfRangeException — silently skip this update and
        // wait for a sane frame.
        var expectedBytes = frame.Width * frame.Height * 4;
        if (frame.Width <= 0 || frame.Height <= 0 || frame.Pixels.Length < expectedBytes)
            return;

        if (_thumbnail is null)
        {
            // 96 DPI matches the WPF default; PixelFormats.Bgra32 matches the
            // engine's BGRA pixel layout so the WritePixels call is a memcpy.
            // The setter fires PropertyChanged for both Thumbnail and HasThumbnail
            // so the DataGrid's Visibility bindings flip in the same change cycle.
            Thumbnail = new WriteableBitmap(
                ThumbnailWidth, ThumbnailHeight, 96, 96, PixelFormats.Bgra32, null);
        }

        var thumb = _thumbnail!;
        thumb.Lock();
        try
        {
            ScaleNearestNeighborBgra(
                src: frame.Pixels.Span,
                srcW: frame.Width,
                srcH: frame.Height,
                dst: thumb.BackBuffer,
                dstStride: thumb.BackBufferStride,
                dstW: ThumbnailWidth,
                dstH: ThumbnailHeight);
            thumb.AddDirtyRect(new Int32Rect(0, 0, ThumbnailWidth, ThumbnailHeight));
        }
        finally
        {
            thumb.Unlock();
        }
    }

    /// <summary>
    /// Inline nearest-neighbor scaler — copies one downsampled BGRA frame into the
    /// WriteableBitmap's back buffer. We don't reuse <see cref="ManagedNearestNeighborFrameScaler"/>
    /// because it allocates a managed buffer per scale; here we want to write
    /// directly into the WriteableBitmap's pinned native memory to avoid a copy.
    ///
    /// The arithmetic is the same: for each destination pixel, compute the source
    /// pixel via integer ratio and copy 4 bytes. At 1Hz × 10 thumbnails that's
    /// ~144,000 pixel reads per second — negligible CPU.
    /// </summary>
    private static void ScaleNearestNeighborBgra(
        ReadOnlySpan<byte> src, int srcW, int srcH,
        IntPtr dst, int dstStride, int dstW, int dstH)
    {
        // Pre-compute the x-ratio table once per call so the inner loop is just two
        // multiplies and a memcpy. Java-style fixed-point would be faster but for
        // 160×90 the overhead is irrelevant.
        Span<int> srcXFor = stackalloc int[dstW];
        for (var x = 0; x < dstW; x++) srcXFor[x] = x * srcW / dstW;

        unsafe
        {
            var dstPtr = (byte*)dst;
            var srcStride = srcW * 4;
            for (var y = 0; y < dstH; y++)
            {
                var srcY = y * srcH / dstH;
                var srcRow = srcY * srcStride;
                var dstRow = y * dstStride;
                for (var x = 0; x < dstW; x++)
                {
                    var srcOff = srcRow + srcXFor[x] * 4;
                    var dstOff = dstRow + x * 4;
                    dstPtr[dstOff + 0] = src[srcOff + 0];
                    dstPtr[dstOff + 1] = src[srcOff + 1];
                    dstPtr[dstOff + 2] = src[srcOff + 2];
                    dstPtr[dstOff + 3] = src[srcOff + 3];
                }
            }
        }
    }

    public Guid Id => _participant.Id;
    public string DisplayName => _participant.DisplayName;
    public string SourceMachine => _participant.CurrentSource?.MachineName ?? "(disconnected)";
    public string SourceFullName => _participant.CurrentSource?.FullName ?? "(disconnected)";
    public bool IsOnline => _participant.CurrentSource is not null;

    public bool IsEnabled
    {
        get => _isEnabled;
        set => SetField(ref _isEnabled, value);
    }

    /// <summary>
    /// When true (default), the operator wants this participant's ISO recorded
    /// when the global recording toggle is on. When false, this participant is
    /// opted out of recording even with global on. The flag is read at the
    /// EnableIsoAsync call so changing it after enabling has no effect on a
    /// running pipeline; operator must disable + re-enable to apply.
    /// </summary>
    public bool RecordToDisk
    {
        get => _recordToDisk;
        set => SetField(ref _recordToDisk, value);
    }
    private bool _recordToDisk = true;

    private long _framesIn;
    private long _framesOut;
    private long _framesDropped;
    private string _incomingResolution = "—";
    private string _incomingFps = "—";

    /// <summary>Number of frames the receiver has captured so far.</summary>
    public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }

    /// <summary>Number of frames the sender has emitted so far.</summary>
    public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }

    /// <summary>Frames dropped by the closest-frame strategy when the receiver outpaces the processor.</summary>
    public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); }

    private string _stateLabel = "—";
    private string _stateColor = "Wd.Text.Tertiary";
    private double _peakAudioLevel;
    private double _displayedAudioLevel;
    private DateTimeOffset _lastPeakAt;

    /// <summary>
    /// Smoothed audio level for display (0.0 to 1.0). Decays toward 0 between
    /// peak updates so the VU bar feels alive even when audio is sparse. The
    /// raw peak from the engine arrives at the 1Hz stats poll; we interpolate
    /// down between polls in the property getter (technically a slight
    /// abstraction leak but simpler than wiring another timer).
    /// </summary>
    public double DisplayedAudioLevel
    {
        get => _displayedAudioLevel;
        private set => SetField(ref _displayedAudioLevel, value);
    }

    /// <summary>
    /// VU-bar fill width as a 0-100 number, suitable for a Width binding on
    /// a fixed-size 100-px-wide indicator. Returns the displayed (decayed)
    /// audio level scaled to [0, 100]; 0 when no recent audio has been seen.
    /// </summary>
    public double AudioLevelWidthPercent => Math.Clamp(_displayedAudioLevel * 100, 0, 100);

    /// <summary>Human-readable pipeline state ("Receiving", "Error", "—").</summary>
    public string StateLabel { get => _stateLabel; set => SetField(ref _stateLabel, value); }

    /// <summary>Resource key of the brush to color the state pill with.</summary>
    public string StateColor { get => _stateColor; set => SetField(ref _stateColor, value); }

    /// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
    public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }

    /// <summary>
    /// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames
    /// have been observed since the pipeline started. Computed in the engine via a
    /// 30-frame moving window.
    /// </summary>
    public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); }

    /// <summary>Updates the live stats display from a controller-side snapshot.</summary>
    public void UpdateStats(IsoHealthStats stats)
    {
        // Audio level: take the new peak when it's higher than what we're
        // currently displaying (instant attack), otherwise decay toward zero
        // (slow release). 0.7 multiplier per 1Hz tick = ~half-life of ~1.6s,
        // which feels like a real VU meter. When the engine starts feeding
        // real PeakAudioLevel values, this code starts working without
        // further changes.
        if (stats.PeakAudioLevel > _displayedAudioLevel)
        {
            _displayedAudioLevel = stats.PeakAudioLevel;
            _lastPeakAt = DateTimeOffset.UtcNow;
        }
        else
        {
            _displayedAudioLevel *= 0.7;
            if (_displayedAudioLevel < 0.01) _displayedAudioLevel = 0;
        }
        _peakAudioLevel = stats.PeakAudioLevel;
        OnPropertyChanged(nameof(DisplayedAudioLevel));
        OnPropertyChanged(nameof(AudioLevelWidthPercent));

        FramesIn = stats.FramesIn;
        FramesOut = stats.FramesOut;
        FramesDropped = stats.FramesDropped;
        IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0
            ? $"{stats.IncomingWidth}×{stats.IncomingHeight}"
            : "—";
        IncomingFps = stats.IncomingFps > 0
            ? $"{stats.IncomingFps:0.0} fps"
            : "—";
        (StateLabel, StateColor) = stats.State switch
        {
            IsoState.Receiving => ("LIVE", "Wd.Status.Live"),
            IsoState.Sending   => ("LIVE", "Wd.Status.Live"),
            IsoState.NoSignal  => ("NO SIGNAL", "Wd.Status.Warn"),
            IsoState.Error     => ("ERROR", "Wd.Status.Error"),
            IsoState.Idle      => (IsEnabled ? "STARTING" : "—", "Wd.Text.Tertiary"),
            _                  => ("—", "Wd.Text.Tertiary"),
        };
    }

    public bool IsProcessing
    {
        get => _isProcessing;
        private set
        {
            if (SetField(ref _isProcessing, value))
                ToggleIsoCommand.RaiseCanExecuteChanged();
        }
    }

    public string CustomName
    {
        get => _customName;
        set => SetField(ref _customName, value);
    }

    public AsyncRelayCommand ToggleIsoCommand { get; }

    /// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>
    public RelayCommand CopySourceNameCommand { get; }

    /// <summary>Open a non-modal floating preview window for this participant. Multi-monitor friendly.</summary>
    public RelayCommand OpenPreviewCommand { get; }
    public RelayCommand SaveSnapshotCommand { get; }

    /// <summary>
    /// Restart the pipeline for this participant: disable, brief pause, re-enable.
    /// Useful when a single feed flakes (drops climb, framerate jitters) without
    /// affecting other ISOs. No-op when the pipeline isn't currently enabled.
    /// </summary>
    public AsyncRelayCommand RestartIsoCommand { get; }

    /// <summary>Refreshes the underlying participant data (called when the controller emits an updated list).</summary>
    public void Update(Participant updated)
    {
        _participant = updated;
        OnPropertyChanged(nameof(DisplayName));
        OnPropertyChanged(nameof(SourceMachine));
        OnPropertyChanged(nameof(SourceFullName));
        OnPropertyChanged(nameof(IsOnline));
    }

    private async Task ToggleIsoAsync()
    {
        IsProcessing = true;
        try
        {
            if (IsEnabled)
            {
                await _controller.DisableIsoAsync(Id, CancellationToken.None);
                IsEnabled = false;
            }
            else
            {
                // Resolve the output name: explicit per-participant CustomName
                // wins; otherwise expand the operator's template (defaults to
                // "TEAMSISO_{guid}" which matches the engine's old hard-coded
                // behavior). Passing the rendered name to EnableIsoAsync as
                // customName overrides the engine's DefaultOutputName path.
                var resolvedName = string.IsNullOrWhiteSpace(_customName)
                    ? Services.OutputNameTemplate.Render(
                        Services.OutputNameTemplate.Get(),
                        Id,
                        DisplayName)
                    : _customName;
                // Per-participant recording opt-out: when RecordToDisk is false,
                // pass a false override so the engine doesn't attach a recorder
                // even if global recording is on.
                bool? recordOverride = _recordToDisk ? null : false;
                await _controller.EnableIsoAsync(
                    Id,
                    resolvedName,
                    recordOverride,
                    CancellationToken.None);
                IsEnabled = true;
            }
        }
        finally
        {
            IsProcessing = false;
        }
    }
}
