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

namespace DragonISO.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.
///
/// Split across partial files by responsibility:
///   â€¢ <c>MainViewModel.cs</c>          â€” fields, properties, constructor (wires commands), OnStatsTick, Dispose
///   â€¢ <c>MainViewModel.TeamsCommands.cs</c>  â€” Mute / Cam / Leave / Share + Join URL + Teams meeting-state poll
///   â€¢ <c>MainViewModel.PresetCommands.cs</c> â€” auto-apply-last-preset path
///   â€¢ <c>MainViewModel.BulkCommands.cs</c>   â€” Stop all / Enable all / Snapshot all
/// </summary>
public sealed partial 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â€¦";

    /// <summary>
    /// Wall-clock at which <see cref="InitializeAsync"/> kicked the engine. Used to
    /// gate the "Scanning for NDI sourcesâ€¦" placeholder so it shows for a few
    /// seconds after launch even when ParticipantCount == 0 (the bleak
    /// "no ndi sources yet" empty state was being shown immediately and
    /// operators assumed the app was broken before discovery had a chance to fire).
    /// Null until InitializeAsync runs.
    /// </summary>
    private DateTimeOffset? _engineStartedAt;

    /// <summary>How long after engine start to keep showing "Scanningâ€¦" instead of the empty-state copy.</summary>
    private static readonly TimeSpan DiscoveryGracePeriod = TimeSpan.FromSeconds(8);

    // _pendingPresetName / Deadline / Applied + the auto-apply path
    // moved to MainViewModel.PresetCommands.cs.

    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; }

    // Recording-marker and roll-recording commands removed â€” recording feature axed.

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

    /// <summary>
    /// Ctrl+T binding â€” cycles dark â†” light theme via ThemeManager.
    /// Persists the operator's choice through UIPreferences.Theme.
    /// The v2 header surfaces this as a click affordance too; the
    /// command exists once so both bindings reach the same path.
    /// </summary>
    public RelayCommand ToggleThemeCommand { get; }

    /// <summary>
    /// Ctrl+K binding â€” opens the v2 command palette. The actual window
    /// open call lives in <see cref="MainWindow"/> (view-side concern);
    /// this command delegates through an Action callback the view sets
    /// after construction so the VM stays unaware of WPF Window types.
    /// </summary>
    public RelayCommand OpenCommandPaletteCommand { get; }
    private Action? _openCommandPalette;

    /// <summary>
    /// Wire the view's palette-opening callback. Called by MainWindow's
    /// constructor right after DataContext is set. Idempotent â€” second
    /// call replaces the first.
    /// </summary>
    public void RegisterCommandPaletteOpener(Action openPalette) =>
        _openCommandPalette = openPalette;

    /// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
    public RelayCommand ShowNotesCommand { 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);
    }

    // Recording-status properties (IsRecording, ActiveRecordingCount,
    // RecordingElapsed, RecordingFreeSpace, IsLowDiskSpace) removed when the
    // recording feature was axed.

    /// <summary>
    /// Total visible participants â€” feeds the v2 transport strip's "PART N"
    /// readout. Updated on every 1Hz stats tick alongside <see cref="LiveCount"/>.
    /// </summary>
    public int ParticipantCount
    {
        get => _participantCount;
        private set => SetField(ref _participantCount, value);
    }
    private int _participantCount;

    /// <summary>
    /// True for the first <see cref="DiscoveryGracePeriod"/> after engine start.
    /// The XAML uses this to swap the empty-state placeholder from the bleak
    /// "no ndi sources yet â€” open teams and start a meeting" copy (which reads
    /// as broken to operators who just launched into an active meeting) to a
    /// neutral "Scanning for NDI sourcesâ€¦" status while NDI Find resolves
    /// mDNS responses. Always false once participants populate.
    /// </summary>
    public bool IsDiscovering
    {
        get => _isDiscovering;
        private set => SetField(ref _isDiscovering, value);
    }
    private bool _isDiscovering;

    /// <summary>
    /// Currently-enabled (live) ISO count â€” feeds the v2 transport strip's
    /// "LIVE N" readout. The number is cyan-tinted when non-zero to draw
    /// the operator's eye to active state.
    /// </summary>
    public int LiveCount
    {
        get => _liveCount;
        private set => SetField(ref _liveCount, value);
    }
    private int _liveCount;

    /// <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();

        // Subscribe directly (no ObserveOn) and marshal to the UI thread inside
        // the callback via Dispatcher.InvokeAsync. The previous ObserveOn(
        // SynchronizationContextScheduler) path captured SynchronizationContext
        // .Current at subscribe time â€” fragile in WPF startup ordering, where
        // the UI thread's SyncContext can be in a transitional state during
        // App.OnStartup and the captured context never pumps subsequent
        // OnNext calls. Direct subscribe + explicit dispatcher marshal is the
        // pattern proven by Console.Program.cs (engine emits, consumer marshals).
        _participantsSub = controller.Participants
            .Subscribe(snapshot => _dispatcher.InvokeAsync(
                () => OnParticipantsChanged(snapshot),
                DispatcherPriority.Background));

        _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â€¦");
        });

        ToggleThemeCommand = new RelayCommand(() =>
        {
            // ThemeManager.Toggle persists the new preference to UIPreferences
            // and fires the resource-dictionary swap on the dispatcher thread.
            Services.ThemeManager.Current.Toggle();
        });

        OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());

        ShowHelpCommand = new RelayCommand(() =>
        {
            // Showing a Window from a VM violates strict MVVM, but Dragon-ISO 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(JoinPastedMeeting);

        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");
    }

    // Body methods extracted to themed partial files:
    //   MainViewModel.BulkCommands.cs    â€” EnableAllOnlineAsync, StopAllIsosAsync, SnapshotAll
    //   MainViewModel.TeamsCommands.cs   â€” MakeTeamsCommand, JoinPastedMeeting,
    //                                      ExtractMeetingTitle, PollTeamsMeetingState
    //   MainViewModel.PresetCommands.cs  â€” RequestApplyPresetOnStartup,
    //                                      LoadPendingPresetFromPreferences,
    //                                      TryAutoApplyPendingPreset

    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);
        // Recording-elapsed timer + disk-free polling removed alongside the rest
        // of the recording surface.

        // Expose counts as VM properties for the v2 transport-strip binding.
        // The strip's "PART 4 Â· LIVE 2" reads these â€” pushing them on the
        // 1Hz tick keeps the cost off the per-frame UI path.
        ParticipantCount = totalParticipants;
        LiveCount = enabledCount;

        // IsDiscovering gates the "Scanning for NDI sourcesâ€¦" placeholder.
        // True for DiscoveryGracePeriod after engine start AS LONG AS we
        // haven't seen any participants yet; once anything arrives we drop
        // out of the discovering state immediately (back to the OK path).
        if (totalParticipants == 0 && _engineStartedAt is { } startedAt)
        {
            IsDiscovering = DateTimeOffset.UtcNow - startedAt < DiscoveryGracePeriod;
        }
        else if (IsDiscovering)
        {
            IsDiscovering = 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
        {
            StatusText = $"{enabledCount}/{totalParticipants} ISOs live";
        }

        // Teams meeting state â€” UIA traversal at 1Hz; off-thread so a slow
        // UIA call doesn't stall the UI tick. Implementation in
        // MainViewModel.TeamsCommands.cs.
        PollTeamsMeetingState();

        // 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â€¦";
        _engineStartedAt = DateTimeOffset.UtcNow;
        IsDiscovering = true;
        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. Implementation in
        // MainViewModel.PresetCommands.cs.
        LoadPendingPresetFromPreferences();
    }

    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 Dragon-ISO'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();
        }
    }

    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();
    }
}
