using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Data;
using System.Windows.Threading;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;

namespace TeamsISO.App.ViewModels;

/// <summary>
/// Top-level view model for the main window. Owns the live collection of <see cref="ParticipantViewModel"/>,
/// the global settings panel, and the alert banner. Subscribes to <see cref="IIsoController"/>'s observables
/// and marshals updates onto the UI dispatcher.
/// </summary>
public sealed class MainViewModel : ObservableObject, IDisposable
{
    private readonly IIsoController _controller;
    private readonly Dispatcher _dispatcher;
    private readonly IDisposable _participantsSub;
    private readonly IDisposable _alertsSub;
    private readonly DispatcherTimer _statsTimer;
    private readonly Dictionary<Guid, ParticipantViewModel> _byId = new();
    private string _statusText = "Starting…";

    // Auto-apply-last-preset bookkeeping. Set on InitializeAsync from disk;
    // cleared once we successfully apply (so we don't re-apply when the
    // participant list later mutates). The grace deadline gives Teams enough
    // time to publish all initial sources after engine start before we attempt
    // the apply — applying before everyone's visible would partially-restore
    // the routing and silently drop assignments for late-appearing participants.
    private string? _pendingPresetName;
    private DateTimeOffset _pendingPresetDeadline;
    private bool _pendingPresetApplied;

    public ObservableCollection<ParticipantViewModel> Participants { get; } = new();

    /// <summary>
    /// Filter-backed view over <see cref="Participants"/>. The DataGrid binds
    /// to this rather than the raw collection so the operator's filter text
    /// hides non-matching rows without mutating the underlying observable
    /// (which would break IsoController's identity tracking).
    /// </summary>
    public ICollectionView ParticipantsView { get; }

    private string _participantFilter = string.Empty;

    /// <summary>
    /// Apply the operator's saved sort preference to <see cref="ParticipantsView"/>.
    /// JoinOrder = no SortDescriptions (whatever order participants are added in);
    /// Alphabetical = ascending by DisplayName; OnlineFirst = IsOnline desc then
    /// DisplayName asc. Called on construction and from <see cref="SetSortMode"/>.
    /// </summary>
    private void ApplySortFromPrefs()
    {
        var prefs = Services.UIPreferences.Load();
        SetSortMode(prefs.ParticipantSort);
    }

    /// <summary>
    /// Re-applies the sort descriptions on the ParticipantsView. Called from the
    /// settings panel when the operator picks a different sort mode.
    /// </summary>
    public void SetSortMode(Services.UIPreferences.SortMode mode)
    {
        _currentSortMode = mode;
        ParticipantsView.SortDescriptions.Clear();
        switch (mode)
        {
            case Services.UIPreferences.SortMode.Alphabetical:
                ParticipantsView.SortDescriptions.Add(new SortDescription(
                    nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
                break;
            case Services.UIPreferences.SortMode.OnlineFirst:
                ParticipantsView.SortDescriptions.Add(new SortDescription(
                    nameof(ParticipantViewModel.IsOnline), ListSortDirection.Descending));
                ParticipantsView.SortDescriptions.Add(new SortDescription(
                    nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
                break;
            case Services.UIPreferences.SortMode.LoudestFirst:
                // Sort by the displayed audio level (which already includes the
                // decay envelope) so participants don't snap-reorder on every
                // tiny audio frame. ParticipantsView.Refresh() at the stats
                // tick re-evaluates the sort with the latest values.
                ParticipantsView.SortDescriptions.Add(new SortDescription(
                    nameof(ParticipantViewModel.DisplayedAudioLevel), ListSortDirection.Descending));
                ParticipantsView.SortDescriptions.Add(new SortDescription(
                    nameof(ParticipantViewModel.DisplayName), ListSortDirection.Ascending));
                break;
            // JoinOrder: leave SortDescriptions empty.
        }
    }
    private Services.UIPreferences.SortMode _currentSortMode = Services.UIPreferences.SortMode.JoinOrder;
    /// <summary>
    /// Live filter substring. Empty = show everyone. Matched case-insensitively
    /// against display name. Setter refreshes the view immediately so the
    /// DataGrid reflows as the operator types.
    /// </summary>
    public string ParticipantFilter
    {
        get => _participantFilter;
        set
        {
            if (SetField(ref _participantFilter, value))
                ParticipantsView.Refresh();
        }
    }
    public GlobalSettingsViewModel Settings { get; }
    public AlertBannerViewModel AlertBanner { get; } = new();
    public ToastViewModel Toast { get; }
    public UpdateBannerViewModel UpdateBanner { get; } = new();

    /// <summary>
    /// Engine-side controller. Exposed so the PresetsDialog (a Window, not a VM)
    /// can re-issue EnableIsoAsync / DisableIsoAsync when applying a preset
    /// without us having to plumb a per-action command through the participant
    /// view-models from the dialog's XAML.
    /// </summary>
    internal IIsoController Controller => _controller;

    /// <summary>
    /// Emergency-stop: disable every running ISO. Bound to a small "Stop all" affordance
    /// near the participants header so an operator can kill all outputs in a single click
    /// when something goes sideways during a live show.
    /// </summary>
    public AsyncRelayCommand StopAllIsosCommand { get; }

    /// <summary>
    /// Bulk-enable: turn on ISOs for every online participant whose pipeline isn't
    /// already running. Useful for "everyone joined, hit one button, every route goes
    /// live." Skips offline rows (no source) and rows already enabled.
    /// </summary>
    public AsyncRelayCommand EnableAllOnlineCommand { get; }

    /// <summary>
    /// Force NDI discovery to rebuild its finder. Surfaced as a small "Refresh" pill
    /// next to the participants header — useful right after Apply Transcoder Topology
    /// or when Teams restarts mid-session and stale TTLs are masking new sources.
    /// </summary>
    public RelayCommand RefreshDiscoveryCommand { get; }

    // ════════════════════════════════════════════════════════════════════════
    // Phase E.3 — In-call controls. Each command drives a UIAutomation lookup
    // against Teams' window tree and reports a toast on outcome. Best-effort:
    // a control-not-found result toasts a hint rather than throwing, since
    // Teams isn't always in a call (the buttons only appear in-call).
    // ════════════════════════════════════════════════════════════════════════

    public RelayCommand ToggleMuteCommand { get; }
    public RelayCommand ToggleCameraCommand { get; }
    public RelayCommand LeaveCallCommand { get; }
    public RelayCommand OpenShareTrayCommand { get; }

    /// <summary>
    /// Drop a timestamped marker into every active recording. Bound to a button
    /// in the IN-CALL bar; eventually wireable to a global hotkey. The marker
    /// label is auto-generated as "Marker @ HH:mm:ss" — operators who want
    /// custom labels can edit manifest.json after the fact.
    /// </summary>
    public RelayCommand DropRecordingMarkerCommand { get; }

    /// <summary>F1 binding — opens the help / cheat-sheet dialog.</summary>
    public RelayCommand ShowHelpCommand { get; }

    /// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
    public RelayCommand ShowNotesCommand { get; }

    /// <summary>
    /// Roll-recording: disable + re-enable every currently-recording pipeline,
    /// starting a fresh recording chunk in a new subdirectory. Operator-friendly
    /// chaptering between show segments without losing already-recorded footage
    /// (the previous chunk is finalized on disable, the next chunk starts clean).
    /// </summary>
    public AsyncRelayCommand RollRecordingCommand { get; }

    /// <summary>Join a Teams meeting from a pasted URL — see <see cref="JoinMeetingUrl"/>.</summary>
    public RelayCommand JoinMeetingCommand { get; }

    /// <summary>Save a PNG snapshot of every enabled participant's current frame.</summary>
    public RelayCommand SnapshotAllCommand { get; }

    /// <summary>
    /// Toggle the ISO for the Nth visible participant (1-based, matches the
    /// numpad layout). Used by the NumPad1..NumPad9 hotkeys; resolves
    /// against ParticipantsView so the index matches what the operator
    /// sees in the current sort + filter.
    /// </summary>
    public RelayCommand<string> ToggleByIndexCommand { get; }

    /// <summary>
    /// Two-way bound to the quick-join input. Whatever the operator pastes
    /// gets handed to <see cref="TeamsLauncher.TryJoinMeeting"/> when the
    /// Join button fires. Cleared on success so the field is ready for the
    /// next paste.
    /// </summary>
    public string JoinMeetingUrl
    {
        get => _joinMeetingUrl;
        set => SetField(ref _joinMeetingUrl, value);
    }
    private string _joinMeetingUrl = string.Empty;

    public string StatusText
    {
        get => _statusText;
        set => SetField(ref _statusText, value);
    }

    /// <summary>
    /// Recording status badge — true when at least one ISO is being recorded.
    /// Polled at the existing 1Hz stats tick rather than via a dedicated change
    /// event, since recording state shifts on enable/disable transitions and
    /// the stats poll already reads each pipeline's state.
    /// </summary>
    public bool IsRecording
    {
        get => _isRecording;
        private set => SetField(ref _isRecording, value);
    }
    private bool _isRecording;

    /// <summary>Number of pipelines currently writing to the recorder.</summary>
    public int ActiveRecordingCount
    {
        get => _activeRecordingCount;
        private set => SetField(ref _activeRecordingCount, value);
    }
    private int _activeRecordingCount;

    /// <summary>
    /// Elapsed time since recording started this session, formatted as
    /// "MM:SS" (or "HH:MM:SS" past an hour). Empty when nothing is
    /// recording. Resets when all recordings stop, restarts on the next
    /// rec-on transition. Useful for operators tracking "how long has the
    /// show been rolling".
    /// </summary>
    public string RecordingElapsed
    {
        get => _recordingElapsed;
        private set => SetField(ref _recordingElapsed, value);
    }
    private string _recordingElapsed = string.Empty;
    private DateTimeOffset? _recordingStartedAt;

    /// <summary>
    /// Free disk space on the recording drive, formatted as "245 GB" /
    /// "1.2 TB" / "8.4 GB". Empty when recording isn't enabled or the
    /// recording path is invalid. Polled at the existing 1Hz stats tick.
    /// </summary>
    public string RecordingFreeSpace
    {
        get => _recordingFreeSpace;
        private set => SetField(ref _recordingFreeSpace, value);
    }
    private string _recordingFreeSpace = string.Empty;

    /// <summary>True when free disk space drops below 10GB — UI cues coral.</summary>
    public bool IsLowDiskSpace
    {
        get => _isLowDiskSpace;
        private set => SetField(ref _isLowDiskSpace, value);
    }
    private bool _isLowDiskSpace;

    /// <summary>True when the REST control surface (or OSC bridge, or both) is listening.</summary>
    public bool IsControlSurfaceRunning
    {
        get => _isControlSurfaceRunning;
        private set => SetField(ref _isControlSurfaceRunning, value);
    }
    private bool _isControlSurfaceRunning;

    /// <summary>Human-readable string for the control-surface tooltip ("REST :9755 + OSC :9000").</summary>
    public string ControlSurfaceText
    {
        get => _controlSurfaceText;
        private set => SetField(ref _controlSurfaceText, value);
    }
    private string _controlSurfaceText = string.Empty;

    /// <summary>
    /// "IN CALL" when Teams is in an active meeting; "READY" when Teams is
    /// running but not in a call; empty when Teams isn't running. Surfaced
    /// as a status pill in the IN-CALL bar so operators with auto-hide on
    /// can see Teams' state without restoring its window.
    /// </summary>
    public string TeamsMeetingState
    {
        get => _teamsMeetingState;
        private set
        {
            if (SetField(ref _teamsMeetingState, value))
                OnPropertyChanged(nameof(HasTeamsState));
        }
    }
    private string _teamsMeetingState = string.Empty;

    /// <summary>True when Teams is currently in a call (Leave button present in UIA tree).</summary>
    public bool IsTeamsInCall
    {
        get => _isTeamsInCall;
        private set => SetField(ref _isTeamsInCall, value);
    }
    private bool _isTeamsInCall;

    /// <summary>True when <see cref="TeamsMeetingState"/> is non-empty. Used to gate visibility of the IN-CALL bar status pill via the existing BoolToVis converter.</summary>
    public bool HasTeamsState => !string.IsNullOrEmpty(_teamsMeetingState);

    /// <summary>True when the local user's mic is muted in the active Teams call.</summary>
    public bool IsLocalMuted
    {
        get => _isLocalMuted;
        private set => SetField(ref _isLocalMuted, value);
    }
    private bool _isLocalMuted;

    /// <summary>True when the local user's camera is off in the active Teams call.</summary>
    public bool IsLocalCameraOff
    {
        get => _isLocalCameraOff;
        private set => SetField(ref _isLocalCameraOff, value);
    }
    private bool _isLocalCameraOff;

    /// <summary>
    /// Elapsed time since the first ISO went live, formatted "HH:mm:ss". Empty
    /// when nothing's running. Useful for operators tracking show length.
    /// Resets when all ISOs go offline (next time one comes back, the timer
    /// starts from 00:00:00 again).
    /// </summary>
    public string SessionElapsed
    {
        get => _sessionElapsed;
        private set => SetField(ref _sessionElapsed, value);
    }
    private string _sessionElapsed = string.Empty;
    public bool IsSessionActive
    {
        get => _isSessionActive;
        private set => SetField(ref _isSessionActive, value);
    }
    private bool _isSessionActive;
    private DateTimeOffset? _sessionStartedAt;

    public MainViewModel(IIsoController controller, Dispatcher dispatcher)
    {
        _controller = controller;
        _dispatcher = dispatcher;
        Toast = new ToastViewModel(dispatcher);
        Settings = new GlobalSettingsViewModel(controller, Toast);

        // Set up the filter-aware view AFTER Participants is non-null. The
        // CollectionView binds to the live collection; Filter callback runs
        // each time Refresh() is called or the collection mutates.
        ParticipantsView = CollectionViewSource.GetDefaultView(Participants);
        ParticipantsView.Filter = obj =>
        {
            if (string.IsNullOrEmpty(_participantFilter)) return true;
            return obj is ParticipantViewModel p &&
                p.DisplayName.Contains(_participantFilter, StringComparison.OrdinalIgnoreCase);
        };
        // Apply the operator's saved sort preference, if any.
        ApplySortFromPrefs();

        _participantsSub = controller.Participants
            .ObserveOn(new SynchronizationContextScheduler(
                System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
            .Subscribe(OnParticipantsChanged);

        _alertsSub = controller.Alerts
            .ObserveOn(new SynchronizationContextScheduler(
                System.Threading.SynchronizationContext.Current ?? new DispatcherSynchronizationContext(_dispatcher)))
            .Subscribe(alert =>
            {
                AlertBanner.Current = alert;
            });

        // 1 Hz stats poll — pull live frame counters from each running pipeline and
        // push them onto the per-participant view models. Cheap (just reads volatile
        // fields on the engine side) and runs on the UI dispatcher so SetField is safe.
        _statsTimer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher)
        {
            Interval = TimeSpan.FromSeconds(1),
        };
        _statsTimer.Tick += OnStatsTick;
        _statsTimer.Start();

        StopAllIsosCommand = new AsyncRelayCommand(StopAllIsosAsync, () => Participants.Any(p => p.IsEnabled));
        EnableAllOnlineCommand = new AsyncRelayCommand(EnableAllOnlineAsync,
            () => Participants.Any(p => p.IsOnline && !p.IsEnabled));
        RefreshDiscoveryCommand = new RelayCommand(() =>
        {
            _controller.RefreshDiscovery();
            Toast.Show("Refreshing NDI discovery…");
        });

        DropRecordingMarkerCommand = new RelayCommand(() =>
        {
            var label = "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
            _controller.AddRecordingMarker(label);
            Toast.Show($"Marker dropped: {label}");
        });

        ShowHelpCommand = new RelayCommand(() =>
        {
            // Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
            // ship a navigation service and a HelpWindow is purely a UI concern.
            // Owner is set so the dialog centers and inherits z-order.
            var help = new HelpWindow { Owner = System.Windows.Application.Current?.MainWindow };
            help.ShowDialog();
        });

        ShowNotesCommand = new RelayCommand(() =>
        {
            var notes = new NotesWindow { Owner = System.Windows.Application.Current?.MainWindow };
            notes.Show(); // non-modal so operators can stamp + read alongside the show
        });

        SnapshotAllCommand = new RelayCommand(SnapshotAll, () => Participants.Any(p => p.IsEnabled));

        ToggleByIndexCommand = new RelayCommand<string>(s =>
        {
            // Numpad / digit hotkeys pass "1".."9" as a string. Resolve
            // against the filtered/sorted view so the index matches what
            // the operator sees on screen, not the underlying storage order.
            if (!int.TryParse(s, out var idx) || idx < 1 || idx > 9) return;
            var i = 0;
            foreach (var item in ParticipantsView)
            {
                if (item is not ParticipantViewModel p) continue;
                if (++i == idx)
                {
                    if (p.ToggleIsoCommand.CanExecute(null))
                        p.ToggleIsoCommand.Execute(null);
                    break;
                }
            }
        });

        JoinMeetingCommand = new RelayCommand(() =>
        {
            // Trim + handle the operator pasting whitespace around the URL.
            var url = (_joinMeetingUrl ?? string.Empty).Trim();
            if (string.IsNullOrEmpty(url)) return;
            if (TeamsLauncher.TryJoinMeeting(url, out var error))
            {
                Toast.Show("Joining Teams meeting…");
                JoinMeetingUrl = string.Empty;
                // If the operator has auto-hide on, kick off the hide watcher
                // so the Teams meeting window goes away as soon as it renders.
                if (Settings.AutoHideTeamsWindows)
                    _ = TeamsLauncher.AutoHideAfterLaunchAsync();
            }
            else
            {
                Toast.Warn($"Could not join: {error}");
            }
        });

        RollRecordingCommand = new AsyncRelayCommand(RollRecordingAsync,
            () => _controller.RecordingEnabled && Participants.Any(p => p.IsEnabled));

        ToggleMuteCommand = MakeTeamsCommand(
            label: "Mute",
            invoke: TeamsControlBridge.ToggleMute,
            successMessage: "Toggled mute");
        ToggleCameraCommand = MakeTeamsCommand(
            label: "Camera",
            invoke: TeamsControlBridge.ToggleCamera,
            successMessage: "Toggled camera");
        LeaveCallCommand = MakeTeamsCommand(
            label: "Leave",
            invoke: TeamsControlBridge.LeaveCall,
            successMessage: "Left the call");
        OpenShareTrayCommand = MakeTeamsCommand(
            label: "Share",
            invoke: TeamsControlBridge.OpenShareTray,
            successMessage: "Opened share tray");
    }

    /// <summary>
    /// Wraps a <see cref="TeamsControlBridge"/> invocation in a RelayCommand that
    /// translates the result to a user-visible toast. Centralizes the toast wording
    /// so the four control commands stay consistent.
    /// </summary>
    private RelayCommand MakeTeamsCommand(string label, Func<TeamsControlBridge.InvokeResult> invoke, string successMessage)
    {
        return new RelayCommand(() =>
        {
            switch (invoke())
            {
                case TeamsControlBridge.InvokeResult.Invoked:
                    Toast.Show(successMessage);
                    break;
                case TeamsControlBridge.InvokeResult.TeamsNotRunning:
                    Toast.Warn("Teams isn't running.");
                    break;
                case TeamsControlBridge.InvokeResult.ControlNotFound:
                    Toast.Warn($"{label} control not found — are you in a call?");
                    break;
                case TeamsControlBridge.InvokeResult.InvokeFailed:
                    Toast.Warn($"{label} button found but disabled.");
                    break;
            }
        });
    }

    /// <summary>
    /// Issues DisableIsoAsync for every participant whose ISO is currently enabled.
    /// Each disable is awaited sequentially so we don't try to tear down N pipelines
    /// in parallel and trip channel-completion races; for ~10 participants this is
    /// still sub-second total. Failures are swallowed (best-effort emergency stop).
    /// </summary>
    /// <summary>
    /// Roll every active recording into a new chunk: disable + re-enable every
    /// pipeline that's currently running. The recorder finalizes its
    /// manifest.json on disable and a fresh subdirectory is created on the
    /// next enable (RawBgraRecorderSink uses the participant display name +
    /// the timestamp template so consecutive rolls don't collide on disk).
    ///
    /// Per-participant best-effort: one bad pipeline doesn't abort the rest.
    /// </summary>
    private async Task RollRecordingAsync()
    {
        var enabled = Participants.Where(p => p.IsEnabled).ToArray();
        if (enabled.Length == 0)
        {
            Toast.Show("No active ISOs to roll");
            return;
        }
        var rolled = 0;
        foreach (var p in enabled)
        {
            try
            {
                await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
                await Task.Delay(150);
                var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
                    ? Services.OutputNameTemplate.Render(
                        Services.OutputNameTemplate.Get(),
                        p.Id,
                        p.DisplayName)
                    : p.CustomName;
                bool? recordOverride = p.RecordToDisk ? null : false;
                await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
                rolled++;
            }
            catch
            {
                // Per-pipeline best-effort
            }
        }
        Toast.Show($"Rolled {rolled} recording(s) into a new chunk");
    }

    /// <summary>
    /// Enable ISOs for every online + non-enabled participant in parallel-ish
    /// (sequential await, but each individual EnableIsoAsync is fast). Tolerates
    /// per-participant failures so one bad source doesn't abort the rest.
    /// </summary>
    private async Task EnableAllOnlineAsync()
    {
        var candidates = Participants.Where(p => p.IsOnline && !p.IsEnabled).ToArray();
        var enabled = 0;
        foreach (var p in candidates)
        {
            try
            {
                var resolvedName = string.IsNullOrWhiteSpace(p.CustomName)
                    ? Services.OutputNameTemplate.Render(
                        Services.OutputNameTemplate.Get(),
                        p.Id,
                        p.DisplayName)
                    : p.CustomName;
                bool? recordOverride = p.RecordToDisk ? null : false;
                await _controller.EnableIsoAsync(p.Id, resolvedName, recordOverride, CancellationToken.None);
                p.IsEnabled = true;
                enabled++;
            }
            catch
            {
                // Per-participant best-effort — one bad source shouldn't abort the bulk operation.
            }
        }
        Toast.Show(enabled == 0
            ? "No participants to enable"
            : $"Enabled {enabled} ISO(s)");
    }

    private async Task StopAllIsosAsync()
    {
        // Snapshot first so the collection doesn't mutate while we iterate.
        var enabled = Participants.Where(p => p.IsEnabled).ToArray();
        if (enabled.Length == 0)
        {
            Toast.Show("No ISOs to stop");
            return;
        }
        // Confirm before tearing down — this button is an "emergency stop" but
        // mis-clicks during a show are easy. The dialog cost is negligible
        // (one Enter press) and the regret cost is huge (yanking 5 ISOs mid-
        // broadcast). Default selection is No so accidental hits cancel.
        var confirm = System.Windows.MessageBox.Show(
            $"Stop {enabled.Length} running ISO(s)?\n\nThis tears down every active pipeline immediately.",
            "TeamsISO — Stop all ISOs",
            System.Windows.MessageBoxButton.YesNo,
            System.Windows.MessageBoxImage.Warning,
            System.Windows.MessageBoxResult.No);
        if (confirm != System.Windows.MessageBoxResult.Yes) return;

        foreach (var p in enabled)
        {
            try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
            catch { /* defensive */ }
            p.IsEnabled = false;
        }
        Toast.Show($"Stopped {enabled.Length} ISO(s)");
    }

    /// <summary>
    /// Save a PNG of every currently-enabled participant's latest processed
    /// frame to a timestamped subdirectory under
    /// %USERPROFILE%\Pictures\TeamsISO\. One folder per Snapshot All click
    /// so back-to-back clicks don't comingle. Useful for end-of-meeting
    /// archives, recapping who showed up, etc.
    /// </summary>
    private void SnapshotAll()
    {
        var enabled = Participants.Where(p => p.IsEnabled).ToArray();
        if (enabled.Length == 0)
        {
            Toast.Warn("No enabled participants to snapshot");
            return;
        }

        var rootDir = System.IO.Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
            "TeamsISO",
            $"snapshots-{DateTimeOffset.Now:yyyyMMdd_HHmmss}");

        try
        {
            System.IO.Directory.CreateDirectory(rootDir);
        }
        catch (Exception ex)
        {
            Toast.Warn($"Couldn't create snapshot dir: {ex.Message}");
            return;
        }

        var saved = 0;
        var failed = 0;
        foreach (var p in enabled)
        {
            try
            {
                var frame = _controller.GetLatestProcessedFrame(p.Id);
                if (frame is null || frame.Pixels.IsEmpty) { failed++; continue; }

                var safeName = string.Join("_", p.DisplayName.Split(System.IO.Path.GetInvalidFileNameChars()));
                if (string.IsNullOrWhiteSpace(safeName)) safeName = "participant";
                var path = System.IO.Path.Combine(rootDir, $"{safeName}.png");

                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);
                saved++;
            }
            catch { failed++; }
        }

        Toast.Show(failed > 0
            ? $"Saved {saved} snapshot(s) ({failed} failed) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}"
            : $"Saved {saved} snapshot(s) to Pictures\\TeamsISO\\{System.IO.Path.GetFileName(rootDir)}");
    }

    /// <summary>
    /// Format a byte count as a human-readable string with 0-1 decimal
    /// places — e.g. "8.4 GB", "245 GB", "1.2 TB". Optimized for footer
    /// readability over precision: nobody needs to know it's 245.34 GB.
    /// </summary>
    private static string FormatBytes(long bytes)
    {
        if (bytes < 0) return "—";
        const long GB = 1024L * 1024 * 1024;
        const long TB = GB * 1024;
        if (bytes >= TB) return $"{bytes / (double)TB:0.0} TB";
        if (bytes >= GB) return bytes >= 100 * GB
            ? $"{bytes / GB} GB"           // 100+ GB: no decimal — clutter
            : $"{bytes / (double)GB:0.0} GB"; // < 100GB: one decimal for the low-warning case
        return $"{bytes / 1024 / 1024} MB";
    }

    /// <summary>
    /// Pull the meaningful "meeting title" out of Teams' raw window title.
    /// Teams uses formats like:
    ///   "Weekly Standup | Microsoft Teams"
    ///   "Meeting with Alice | Microsoft Teams"
    ///   "Microsoft Teams" (no meeting, just the app)
    /// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
    /// short and readable. Truncate beyond 50 chars so a long meeting
    /// subject doesn't push the rest of the IN-CALL bar off screen.
    /// </summary>
    internal static string ExtractMeetingTitle(string windowTitle)
    {
        if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
        var t = windowTitle.Trim();
        // Common separator patterns Teams uses across locales.
        foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
        {
            var idx = t.IndexOf(sep, StringComparison.Ordinal);
            if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
        }
        // If after stripping we're left with just "Microsoft Teams" the
        // window has no meeting context — return empty so the pill stays
        // at "IN CALL" without a stale title.
        if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
        if (t.Length > 50) t = t.Substring(0, 47) + "…";
        return t;
    }

    private void OnStatsTick(object? sender, EventArgs e)
    {
        foreach (var vm in Participants)
        {
            try
            {
                var stats = _controller.GetStats(vm.Id);
                vm.UpdateStats(stats);
                // Refresh preview thumbnail from the engine's most recent
                // processed frame. Returns null if no pipeline is running for
                // this participant; UpdateThumbnail short-circuits in that
                // case, leaving the previous frame in place rather than
                // visibly blanking when the pipeline restarts.
                vm.UpdateThumbnail(_controller.GetLatestProcessedFrame(vm.Id));
            }
            catch
            {
                // Stats are advisory; never let a transient read failure
                // tear down the timer or surface an error to the user.
            }
        }

        // Active-speaker highlight: find the loudest enabled participant
        // and mark their IsActiveSpeaker flag. Only one row at a time;
        // ties broken by enumeration order (first one wins). Threshold of
        // 0.05 prevents constant flicker between near-silent participants
        // when nobody's really speaking.
        ParticipantViewModel? loudest = null;
        double loudestLevel = 0.05;
        foreach (var p in Participants)
        {
            if (!p.IsEnabled) continue;
            if (p.DisplayedAudioLevel > loudestLevel)
            {
                loudest = p;
                loudestLevel = p.DisplayedAudioLevel;
            }
        }
        foreach (var p in Participants)
        {
            var shouldHighlight = ReferenceEquals(p, loudest);
            if (p.IsActiveSpeaker != shouldHighlight)
                p.IsActiveSpeaker = shouldHighlight;
        }

        // If sort mode is LoudestFirst, refresh the view so the new audio
        // peaks re-evaluate the sort. Skipped for the other sort modes
        // since their keys (name, online state) don't change every tick —
        // no need to pay the Refresh cost.
        if (_currentSortMode == Services.UIPreferences.SortMode.LoudestFirst)
        {
            try { ParticipantsView.Refresh(); }
            catch { /* defensive — Refresh occasionally throws on collection mutations */ }
        }

        // Update footer badges. Recording count is "ISOs that have a recorder
        // attached" — _controller.RecordingEnabled tells us the global toggle,
        // but the actual recorder count = number of running pipelines while
        // that toggle was on (transient enables can mean fewer recorders than
        // running pipelines). Approximate by ANDing global toggle + running
        // ISO count; close enough for an at-a-glance footer.
        var totalParticipants = Participants.Count;
        var enabledCount = Participants.Count(p => p.IsEnabled);
        ActiveRecordingCount = _controller.RecordingEnabled ? enabledCount : 0;
        IsRecording = ActiveRecordingCount > 0;

        // Recording session timer — independent of the global session timer
        // since recording can start AFTER the meeting begins (or vice versa)
        // and operators want to know exactly how long the archive copy has
        // been rolling. Resets to null when all recordings stop, so the
        // next rec-on transition starts the timer from 00:00.
        if (IsRecording)
        {
            _recordingStartedAt ??= DateTimeOffset.UtcNow;
            var recElapsed = DateTimeOffset.UtcNow - _recordingStartedAt.Value;
            RecordingElapsed = recElapsed.TotalHours >= 1
                ? $"{(int)recElapsed.TotalHours:D2}:{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}"
                : $"{recElapsed.Minutes:D2}:{recElapsed.Seconds:D2}";

            // Disk free on the recording drive. DriveInfo is cheap (a
            // single GetDiskFreeSpaceEx call). We tolerate any failure
            // path silently — disk-space display is a comfort feature, not
            // a critical signal, and the existing DiskSpaceWatcher already
            // handles the auto-disable-at-1GB safety net.
            try
            {
                var dir = Settings.RecordingDirectory;
                if (!string.IsNullOrEmpty(dir))
                {
                    var root = System.IO.Path.GetPathRoot(dir);
                    if (!string.IsNullOrEmpty(root))
                    {
                        var drive = new System.IO.DriveInfo(root);
                        if (drive.IsReady)
                        {
                            var freeBytes = drive.AvailableFreeSpace;
                            RecordingFreeSpace = FormatBytes(freeBytes);
                            IsLowDiskSpace = freeBytes < 10L * 1024 * 1024 * 1024; // <10GB
                        }
                    }
                }
            }
            catch { /* defensive — drive enumeration occasionally throws on network paths */ }
        }
        else
        {
            _recordingStartedAt = null;
            RecordingElapsed = string.Empty;
            RecordingFreeSpace = string.Empty;
            IsLowDiskSpace = false;
        }

        // Session timer — start on first ISO going live, reset when none are
        // live anymore. Subsequent enables after a full-zero gap restart the
        // timer rather than resuming, which is the operator's mental model:
        // "the show started when the first feed went live."
        if (enabledCount > 0)
        {
            _sessionStartedAt ??= DateTimeOffset.UtcNow;
            var elapsed = DateTimeOffset.UtcNow - _sessionStartedAt.Value;
            SessionElapsed = elapsed.TotalHours >= 1
                ? $"{(int)elapsed.TotalHours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"
                : $"{elapsed.Minutes:D2}:{elapsed.Seconds:D2}";
            IsSessionActive = true;
        }
        else if (_sessionStartedAt is not null)
        {
            _sessionStartedAt = null;
            SessionElapsed = string.Empty;
            IsSessionActive = false;
        }

        // Dynamic status text — replaces the static "Engine running at X fps"
        // once ISOs are live. The framerate target is still implicit (the user
        // set it in OUTPUT settings; surfacing it constantly steals footer
        // real estate from more-actionable info).
        if (totalParticipants == 0)
        {
            StatusText = "Discovering NDI sources…";
        }
        else if (enabledCount == 0)
        {
            StatusText = totalParticipants == 1
                ? "1 participant visible"
                : $"{totalParticipants} participants visible";
        }
        else if (ActiveRecordingCount > 0 && ActiveRecordingCount != enabledCount)
        {
            StatusText = $"{enabledCount}/{totalParticipants} ISOs live · {ActiveRecordingCount} recording";
        }
        else if (ActiveRecordingCount > 0)
        {
            StatusText = $"{enabledCount}/{totalParticipants} ISOs live · all recording";
        }
        else
        {
            StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
        }

        // Teams meeting state — UIA traversal at 1Hz. We probe by looking
        // for the Leave button in Teams' automation tree (present iff in a
        // call) and surface the result as a status pill in the IN-CALL bar.
        // Offloaded to a Task so a slow UIA call doesn't stall the UI tick;
        // the property update is dispatched back here on next tick.
        try
        {
            var teamsRunning = TeamsLauncher.IsRunning();
            if (!teamsRunning)
            {
                TeamsMeetingState = string.Empty;
                IsTeamsInCall = false;
            }
            else
            {
                // Fire the UIA probe off-thread — it walks the full descendant
                // tree of every Teams window and can take 50-200ms in a busy
                // call. We can tolerate one-tick latency on the displayed
                // state much more easily than a UI hiccup.
                _ = Task.Run(() =>
                {
                    try
                    {
                        // Single UIA traversal returns all three signals — in-call,
                        // muted, camera-off — so we don't pay for three walks of
                        // the same descendant tree at 1Hz.
                        var snap = TeamsControlBridge.DetectCallState();
                        var inCall = snap.IsInCall;
                        var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
                        _dispatcher.InvokeAsync(() =>
                        {
                            var previousInCall = IsTeamsInCall;
                            IsTeamsInCall = inCall;
                            TeamsMeetingState = inCall
                                ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
                                : "READY";
                            // Mute / camera state — only meaningful in-call.
                            IsLocalMuted = inCall && (snap.IsMuted ?? false);
                            IsLocalCameraOff = inCall && (snap.IsCameraOff ?? false);

                            // Auto-record on meeting transitions. False→True
                            // turns recording on; True→False turns it off.
                            // Guarded against repeating on every poll by
                            // gating on the actual transition.
                            if (Settings.AutoRecordOnCall && previousInCall != inCall)
                            {
                                if (inCall && !_controller.RecordingEnabled)
                                {
                                    try
                                    {
                                        _controller.SetRecording(true, Settings.RecordingDirectory);
                                        Settings.RecordIsosToDisk = true;
                                        Toast.Show("Auto-record: meeting started — recording ON");
                                    }
                                    catch { /* defensive — recording shouldn't block other state */ }
                                }
                                else if (!inCall && _controller.RecordingEnabled)
                                {
                                    try
                                    {
                                        _controller.SetRecording(false, null);
                                        Settings.RecordIsosToDisk = false;
                                        Toast.Show("Auto-record: meeting ended — recording OFF");
                                    }
                                    catch { /* defensive */ }
                                }
                            }
                        });
                    }
                    catch { /* UIA flakiness shouldn't crash the stats tick */ }
                });
            }
        }
        catch { /* defensive — probe failures must never break the tick */ }

        // Control-surface state — peek at App's owned services.
        var app = System.Windows.Application.Current as App;
        var rest = app?.ControlSurface?.IsRunning ?? false;
        var osc = app?.OscBridge?.IsRunning ?? false;
        IsControlSurfaceRunning = rest || osc;
        // When LAN-reachable mode is on, the footer text shows the routable
        // URL instead of just the port — operators setting up a thin client
        // shouldn't have to open Settings to find what to type into a
        // browser. We trust the Settings VM's ControlSurfaceLanReachable
        // boolean since that's where the toggle is persisted.
        var lanMode = rest && (app?.ControlSurface?.BoundToLan ?? false);
        var lanHost = lanMode ? Settings.ControlSurfaceUrl.Replace("/ui", "") : null;
        ControlSurfaceText = (rest, osc) switch
        {
            (true, true) when lanMode => $"{lanHost} + OSC :{app!.OscBridge!.Port}",
            (true, false) when lanMode => lanHost!,
            (true, true) => $"REST :{app!.ControlSurface!.Port} + OSC :{app.OscBridge!.Port}",
            (true, false) => $"REST :{app!.ControlSurface!.Port}",
            (false, true) => $"OSC :{app!.OscBridge!.Port}",
            _ => string.Empty,
        };
    }

    public async Task InitializeAsync(CancellationToken cancellationToken)
    {
        StatusText = "Discovering NDI sources…";
        await _controller.StartAsync(cancellationToken);
        StatusText = $"Engine running at {_controller.GlobalSettings.FramerateHz:F2} fps target.";

        // Auto-apply last preset bookkeeping. We don't apply here — participants
        // haven't been discovered yet — instead we record the intent and let
        // OnParticipantsChanged trigger the apply once the meeting has populated.
        try
        {
            var pref = Services.OperatorPresetStore.GetStartupPreference();
            if (pref.AutoApplyOnStartup && !string.IsNullOrEmpty(pref.LastAppliedName))
            {
                _pendingPresetName = pref.LastAppliedName;
                // 30s grace window is generous: Teams typically advertises all
                // existing participants within 5–10s of NDI discovery starting.
                // After this deadline we apply with whoever is visible.
                _pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
            }
        }
        catch { /* preset read failures shouldn't block engine startup */ }
    }

    /// <summary>
    /// CLI override for the launch-time auto-apply. Called from App.OnStartup
    /// after parsing <c>--apply-preset</c>. Sets the same pending-preset state
    /// that the user-toggled auto-apply path uses, so a single trigger flow
    /// covers both. Wins over the persisted preference (operator's CLI intent
    /// is more recent than what's on disk).
    /// </summary>
    public void RequestApplyPresetOnStartup(string presetName)
    {
        _pendingPresetName = presetName;
        _pendingPresetDeadline = DateTimeOffset.UtcNow.AddSeconds(30);
        _pendingPresetApplied = false;
    }

    private void OnParticipantsChanged(IReadOnlyList<Participant> incoming)
    {
        var seenIds = new HashSet<Guid>();
        var hideLocal = Settings.HideLocalSelf;
        var autoDisable = Settings.AutoDisableOnDeparture;
        foreach (var p in incoming)
        {
            // The new Teams client emits a "(Local)" pseudo-participant for the user's
            // own preview — operators rarely want it as a routable ISO. Suppress when
            // HideLocalSelf is on (default).
            if (hideLocal && IsLocalSelf(p)) continue;

            seenIds.Add(p.Id);
            if (_byId.TryGetValue(p.Id, out var vm))
            {
                var wasOnline = vm.IsOnline;
                vm.Update(p);
                // Departure: source went from non-null to null. Always toast so the
                // operator notices, even when AutoDisableOnDeparture is off — the
                // ISO might still be "running" but emitting a slate frame, which
                // looks fine in TeamsISO's UI but is broken downstream.
                if (wasOnline && !vm.IsOnline && vm.IsEnabled)
                {
                    if (autoDisable)
                    {
                        var captured = vm;
                        _ = Task.Run(async () =>
                        {
                            try { await _controller.DisableIsoAsync(captured.Id, CancellationToken.None); }
                            catch { /* defensive */ }
                            await _dispatcher.InvokeAsync(() =>
                            {
                                captured.IsEnabled = false;
                                Toast.Show($"Auto-disabled ISO: {captured.DisplayName} left the meeting");
                            });
                        });
                    }
                    else
                    {
                        // ISO stays running on a slate frame; warn the operator so
                        // they can decide whether to disable manually.
                        Toast.Warn($"{vm.DisplayName} disconnected — ISO still running on slate");
                    }
                }
            }
            else
            {
                vm = new ParticipantViewModel(_controller, p, Toast);
                _byId[p.Id] = vm;
                Participants.Add(vm);
            }
        }

        // Remove participants no longer present (or now hidden by the filter).
        for (var i = Participants.Count - 1; i >= 0; i--)
        {
            var vm = Participants[i];
            if (!seenIds.Contains(vm.Id))
            {
                _byId.Remove(vm.Id);
                Participants.RemoveAt(i);
            }
        }

        // Auto-apply-last-preset, second half: once participants populate, kick the
        // apply. We fire it under either of two conditions: (a) every display name
        // referenced by the preset is present (best case — the meeting is fully
        // populated, no skipped assignments), or (b) the grace deadline has passed
        // (give up waiting and apply with whoever's online).
        if (_pendingPresetName is not null && !_pendingPresetApplied)
        {
            TryAutoApplyPendingPreset();
        }
    }

    /// <summary>
    /// Attempts to apply <see cref="_pendingPresetName"/> if either every preset
    /// assignment matches a live participant, or the grace deadline has passed.
    /// Idempotent — repeat calls without state change are no-ops; once we fire we
    /// flag <c>_pendingPresetApplied</c> so subsequent participant churn doesn't
    /// trigger a second apply. Failures (missing preset on disk, preset that no
    /// longer matches anyone) are swallowed: the operator can always re-apply
    /// manually via the dialog. Delegates to <see cref="PresetApplier.ApplyAsync"/>
    /// for the actual reconciliation so the dialog, REST surface, and this auto-
    /// apply path all share a single implementation.
    /// </summary>
    private void TryAutoApplyPendingPreset()
    {
        Services.OperatorPresetStore.Preset? preset;
        try { preset = Services.OperatorPresetStore.Find(_pendingPresetName!); }
        catch { preset = null; }
        if (preset is null)
        {
            _pendingPresetApplied = true; // give up; nothing on disk to apply
            return;
        }

        var liveNames = new HashSet<string>(
            Participants.Select(p => p.DisplayName),
            StringComparer.OrdinalIgnoreCase);
        var allPresent = preset.Assignments.All(a => liveNames.Contains(a.DisplayName));
        if (!allPresent && DateTimeOffset.UtcNow < _pendingPresetDeadline)
            return; // wait for the rest of the meeting to populate

        _pendingPresetApplied = true;
        var captured = preset;
        // Snapshot the participants list since we're about to await on a worker
        // thread; the live ObservableCollection isn't safe to enumerate from
        // outside the dispatcher.
        var snapshot = Participants.ToList();
        _ = Task.Run(async () =>
        {
            var result = await PresetApplier.ApplyAsync(
                captured, snapshot, _controller, _dispatcher);
            await _dispatcher.InvokeAsync(() =>
                Toast.Show($"Auto-applied preset \"{captured.Name}\" — {result.Matched} participant(s)"));
        });
    }

    private static bool IsLocalSelf(Participant p) =>
        string.Equals(p.DisplayName, "(Local)", StringComparison.Ordinal);

    public void Dispose()
    {
        _statsTimer.Stop();
        _statsTimer.Tick -= OnStatsTick;
        _participantsSub.Dispose();
        _alertsSub.Dispose();
    }
}
