using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;

namespace ytLive.ViewModels;

public class MainViewModel : ViewModelBase
{
    private readonly YouTubeAuthService _youtubeAuth;
    private readonly YouTubeStreamService _youtubeStream;
    private readonly YouTubeChatService _youtubeChat;
    private readonly DispatcherTimer _liveTimer;
    private readonly DispatcherTimer _volumeFlashTimer;
    private readonly DispatcherTimer _gameVolumeFlashTimer;
    private readonly DispatcherTimer _gameAudioTimer;

    private Scene? _activeScene;
    private SceneElement? _selectedElement;
    private ImageSource? _activeBackgroundImage;
    private bool _isConnected;
    private string _accountAvatarUrl = string.Empty;
    private string _accountDisplayName = string.Empty;
    private double _audioLevel;
    private bool _volumeAdjusting;
    private bool _volumeFlash;
    private double _micVolume = 0.8;
    private bool _micMuted;
    private double? _volumeBeforeMute;
    private string? _micSourceName;
    private MicStatus _micStatus = MicStatus.NotConnected;
    private double _gameAudioLevel;
    private bool _gameVolumeAdjusting;
    private bool _gameVolumeFlash;
    private double _gameVolume = 1.0;
    private bool _gameMuted;
    private double? _gameVolumeBeforeMute;
    private bool _gameAudioBarActive;

    // TRAX (TASK 9): free background music at a fixed quiet volume, playing to
    // the default device so it rides the loopback into the stream. The dot is
    // red (no track) / yellow (loaded, stopped) / green (playing).
    private readonly MusicPlayer _musicPlayer = new();
    private Music? _music;
    private bool _musicPlaying;
    private AudioGainProvider? _gainProvider;
    private StreamStatus _streamStatus = StreamStatus.Offline;
    private StreamHealth _currentHealth = new();
    private string _streamTitle = string.Empty;
    private string _streamDescription = string.Empty;
    private string _streamVisibility = "Private";
    private string? _currentBroadcastId;
    private string _windowTitle = "ytLlive";
    private string _topBarBackground = "#16213e";
    private string _previewGlowBrush = "Transparent";
    private Thickness _previewGlowThickness = new(0);
    private string _liveElapsedText = "00:00:00";
    private double _recDotPulse = 1.0;
    private TimeSpan _liveElapsed;

    private bool _isSettingsOpen;
    private bool _isBugOpen;
    private bool _isFeatureOpen;
    private bool _isAboutOpen;
    private bool _isLicensesOpen;
    private string _overlayTitle = string.Empty;
    private string _defaultStreamTitle = string.Empty;
    private string _defaultStreamDescription = string.Empty;
    private string _bugReportText = string.Empty;
    private string _bugReportEmail = string.Empty;
    private string _featureRequestText = string.Empty;
    private string _featureRequestEmail = string.Empty;

    private LayoutStore _layoutStore;
    private string? _activeLayoutPath;
    private DispatcherTimer? _saveDebounce;
    private bool _isLoading;

    // Webcam: one camera input app-wide. The identity (DeviceId) lives on the
    // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
    // (webcam.{scene}.config). CameraManager still owns the single session.
    private readonly ICameraEnumerator _cameraEnumerator;
    private readonly CameraManager _cameraManager;
    private Webcam? _webcam;
    private string? _webcamError;
    private SocialsConfig? _socials;
    private VideoFrame? _socialBarFrame;
    private readonly IMicrophoneEnumerator _microphoneEnumerator;

    // Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback,
    // mic via WASAPI capture, both owned by the mixer. Capture runs for the app's
    // lifetime (started at startup, stopped on shutdown) so the footer meters
    // stay live in preview. Mic level feeds AudioLevel (the meter); loopback
    // feeds the game audio bar's meter. Private by design — the mixer surfaces
    // the levels + mic connection state to the UI.
    private readonly AudioMixer _audioMixer;

    // Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
    // tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
    // until the live-stream create flow (TASK 5) supplies the reusable stream URL.
    private readonly Func<string?> _rtmpUrlProvider;
    private readonly FramePump _framePump;

    // Screen backdrop: a permanent live capture (desktop/game) that every scene
    // shows at the bottom layer. One shared capture session per key — the
    // ScreenCaptureManager refcounts by key, mirroring CameraManager.
    private readonly IFullScreenDetector _fullScreenDetector;
    private readonly ScreenCaptureManager _screenCaptureManager;
    private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
    private readonly IGameAudioDetector _gameAudioDetector;
    private int? _lastForegroundFullScreenMonitor;
    private CancellationTokenSource? _deactivateCts;
    private ImageSource? _backdropImage;
    private HashSet<string> _liveCaptureKeys = new();

    // Branding flash (monetization): a full-frame "made with ytLlive!" shown
    // for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
    // BrandFlashEnabled = false. See ai.md "Monetization".
    private readonly DispatcherTimer _brandFlashTimer;
    private readonly DispatcherTimer _brandFlashOffTimer;
    private bool _brandFlashEnabled = true;
    private bool _brandFlashActive;

    private const string SupportEmail = "gramps@llamachile.shop";
    // The creator-hub link targets (About overlay) — public so the hub's
    // integration test can assert they point at real destinations.
    public const string ChannelUrl = "https://youtube.com/@llamachileshop";
    public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
    // Tabled 2026-08-14: the billing provider is not chosen yet (Gumroad/Lemon Squeezy — TASK 23);
    // the subscription page (premium unlock) lands here once set up. Coffee/thanks URL is live.
    public const string PremiumUrl = "";
    public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
    public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";

    /// <summary>The About hub's Unlock Premium button lights up only once the
    /// billing URL is set (tabled 2026-08-14 — provider undecided, TASK 23).</summary>
    public bool IsPremiumAvailable => PremiumUrl.Length > 0;

    /// <summary>Full in-app licensing/legal text, loaded on demand from the
    /// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
    public string LicensesText { get; private set; } = string.Empty;

    public ObservableCollection<Scene> Scenes { get; } = new();
    public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
    public ObservableCollection<DisplayInfo> Displays { get; } = new();

    public Scene? ActiveScene
    {
        get => _activeScene;
        set
        {
            if (value != null && value.IsHidden) return;
            if (SetProperty(ref _activeScene, value))
            {
                SelectedElement = null;
                OnPropertyChanged(nameof(ShowChatInactiveMessage));
                OnPropertyChanged(nameof(ShowEmptySceneHint));
                OnPropertyChanged(nameof(ShowPreviewPlaceholder));
                OnPropertyChanged(nameof(ShowSourcesEmptyHint));
                OnPropertyChanged(nameof(CanChangeBackdrop));
                OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
                OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
                UpdateActiveBackground();
                UpdateBackdropImage();
            }
        }
    }

    public ImageSource? ActiveBackgroundImage
    {
        get => _activeBackgroundImage;
        private set => SetProperty(ref _activeBackgroundImage, value);
    }

    /// <summary>The active scene's live-capture backdrop frame (rendered below the
    /// static background). Set from the shared capture bitmap as it arrives.</summary>
    public ImageSource? BackdropImage
    {
        get => _backdropImage;
        private set
        {
            if (SetProperty(ref _backdropImage, value))
                OnPropertyChanged(nameof(ShowPreviewPlaceholder));
        }
    }

    public SceneElement? SelectedElement
    {
        get => _selectedElement;
        set
        {
            if (SetProperty(ref _selectedElement, value))
                OnPropertyChanged(nameof(IsWebcamSelected));
        }
    }

    /// <summary>The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.</summary>
    public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;

    /// <summary>
    /// Right-click-on-preview → "Show Webcam": offered when the active scene has no
    /// webcam config (add one) or hides it (unhide — keeps the config row).
    /// </summary>
    public bool CanShowWebcamInActiveScene
        => ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);

    /// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
    public bool CanChangeWebcam => _webcam != null;

    // ─── Social bar (global resource, shown on every scene) ───

    private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
    private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
    private static SolidColorBrush CreateBrush(string hex)
        => (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;

    /// <summary>True when the global socials config has at least one validated entry.</summary>
    public bool HasSocials => _socials != null && _socials.Entries.Count > 0;

    /// <summary>The bar is on the stream when it's enabled and has entries.</summary>
    public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials;

    /// <summary>Footer indicator dot: green when the bar is on the stream, red otherwise.</summary>
    public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush;

    /// <summary>Green glow on the bar while it's on the stream.</summary>
    public SolidColorBrush SocialBarGlowBrush => BarOnBrush;

    /// <summary>The global socials config (entries + bar settings), or null.</summary>
    public SocialsConfig? Socials => _socials;

    /// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
    private static bool IsPremium => false; // unlock deferred (TASK 23) — seam only

    /// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
    public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;

    private readonly ISocialValidator _socialValidator = new HttpSocialValidator();

    /// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
    public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;

    /// <summary>
    /// The reason the webcam feed is down (device in use, offline, locked, no frames),
    /// or null when it's alive. Surfaced as a red chip so a dead camera is never a
    /// silent empty box. Cleared the moment a real frame arrives.
    /// </summary>
    public string? WebcamError
    {
        get => _webcamError;
        private set => SetProperty(ref _webcamError, value);
    }

    public StreamStatus StreamStatus
    {
        get => _streamStatus;
        set
        {
            if (SetProperty(ref _streamStatus, value))
            {
                OnPropertyChanged(nameof(IsOffline));
                OnPropertyChanged(nameof(IsLive));
                OnPropertyChanged(nameof(RecDotBrush));
                OnPropertyChanged(nameof(RecTextBrush));
                OnPropertyChanged(nameof(RecDotOpacity));
                OnPropertyChanged(nameof(IsLivePrivate));
                OnPropertyChanged(nameof(ShowStartStream));
                OnPropertyChanged(nameof(ShowChatInactiveMessage));
                OnPropertyChanged(nameof(ShowPreviewPlaceholder));
                UpdateLiveVisuals();
            }
        }
    }

    public bool IsConnected
    {
        get => _isConnected;
        set
        {
            if (SetProperty(ref _isConnected, value))
                OnPropertyChanged(nameof(ShowStartStream));
        }
    }

    /// <summary>The connected YouTube account's avatar + name — shown in the top
    /// bar so the creator always sees WHICH account is about to go live.</summary>
    public string AccountAvatarUrl
    {
        get => _accountAvatarUrl;
        private set => SetProperty(ref _accountAvatarUrl, value);
    }

    public string AccountDisplayName
    {
        get => _accountDisplayName;
        private set => SetProperty(ref _accountDisplayName, value);
    }

    // ─── Audio: the mic bar (meter + volume + mute + status dot) is always
    // ─── visible; the game bar (meter + volume + mute) appears only while a
    // ─── full-screen game is producing sound. Both meters preview live. ───

    /// <summary>Live mic input level (0..1) — fed by the audio mixer once capture
    /// lands; 0 with no input. Read by the meter, scaled by MicVolume.</summary>
    public double AudioLevel
    {
        get => _audioLevel;
        set
        {
            if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1)))
            {
                OnPropertyChanged(nameof(MeterFillWidth));
                OnPropertyChanged(nameof(MeterBrush));
            }
        }
    }

    /// <summary>Displayed meter level: 0 while muted; the volume position while
    /// the slider is being dragged (or briefly after an unmute, so the restored
    /// level flashes on the bar) so the creator sees where they're setting it;
    /// otherwise the realtime live level scaled by volume (raising the volume
    /// moves ambient noise up the bar). Read-only meter.</summary>
    private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume);

    public double MeterFillWidth => MeterLevel * 288;

    public string MeterBrush => MeterLevel switch
    {
        < 0.6 => "#22c55e",
        < 0.8 => "#eab308",
        _ => "#ef4444",
    };

    /// <summary>Previewes the volume position while the slider is dragged; the
    /// bar returns to the live level on release (empty when there is no input).
    /// Starting a drag cancels any pending unmute flash.</summary>
    public void SetVolumeAdjusting(bool adjusting)
    {
        if (adjusting)
            CancelVolumeFlash();
        if (SetProperty(ref _volumeAdjusting, adjusting))
        {
            OnPropertyChanged(nameof(MeterFillWidth));
            OnPropertyChanged(nameof(MeterBrush));
        }
    }

    private void BeginVolumeFlash()
    {
        _volumeFlash = true;
        OnPropertyChanged(nameof(MeterFillWidth));
        OnPropertyChanged(nameof(MeterBrush));
        _volumeFlashTimer.Stop();
        _volumeFlashTimer.Start();
    }

    private void EndVolumeFlash()
    {
        _volumeFlashTimer.Stop();
        if (_volumeFlash)
        {
            _volumeFlash = false;
            OnPropertyChanged(nameof(MeterFillWidth));
            OnPropertyChanged(nameof(MeterBrush));
        }
    }

    private void CancelVolumeFlash()
    {
        _volumeFlashTimer.Stop();
        _volumeFlash = false;
    }

    /// <summary>Mic gain (0..1). Running to 0 mutes (the speaker flips to muted);
    /// moving up from 0 unmutes (the mute indicator clears). The level being left
    /// is remembered so the speaker button can restore it.</summary>
    public double MicVolume
    {
        get => _micVolume;
        set
        {
            var clamped = Math.Clamp(value, 0, 1);
            if (clamped == 0 && !_micMuted)
                _volumeBeforeMute ??= _micVolume;
            if (SetProperty(ref _micVolume, clamped))
            {
                var muted = clamped == 0;
                if (_micMuted != muted)
                {
                    _micMuted = muted;
                    OnPropertyChanged(nameof(MicMuted));
                    OnPropertyChanged(nameof(MicMuteText));
                }
                if (!muted)
                    _volumeBeforeMute = null;
                OnPropertyChanged(nameof(MeterFillWidth));
                OnPropertyChanged(nameof(MeterBrush));
            }
        }
    }

    /// <summary>Read-only: true whenever the volume is 0. Only MicVolume may
    /// change it, so the slider and the speaker can never disagree.</summary>
    public bool MicMuted => _micMuted;

    public string MicMuteText => MicMuted ? "Unmute" : "Mute";

    /// <summary>Name of the picked voice source, shown left-justified INSIDE the meter
    /// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here.</summary>
    public string? MicSourceName
    {
        get => _micSourceName;
        private set => SetProperty(ref _micSourceName, value);
    }

    private void ToggleMicMute()
    {
        if (MicMuted)
        {
            MicVolume = _volumeBeforeMute ?? 0.8;
            BeginVolumeFlash();
        }
        else
        {
            _volumeBeforeMute = MicVolume;
            MicVolume = 0;
        }
    }

    private void PickMicrophone()
    {
        var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator))
        {
            Owner = System.Windows.Application.Current?.MainWindow
        };
        if (dialog.ShowDialog() == true && dialog.PickedDevice != null)
        {
            MicSourceName = dialog.PickedDevice.DisplayName;
            _layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts
            _audioMixer.RestartMic(); // swap the live device immediately
        }
    }

    // ─── Mic status dot (the MIC button) ───

    private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f");

    /// <summary>Mic connection state, driven by the mixer's MicConnected/MicFailed
    /// events and the startup device check (see StartMicCaptureAsync).</summary>
    public MicStatus MicStatus
    {
        get => _micStatus;
        private set
        {
            if (SetProperty(ref _micStatus, value))
            {
                OnPropertyChanged(nameof(MicStatusBrush));
                OnPropertyChanged(nameof(MicStatusToolTip));
            }
        }
    }

    /// <summary>Status dot: green = connected, yellow = problem with the requested
    /// connection, red = not connected (no device / not started).</summary>
    public SolidColorBrush MicStatusBrush => MicStatus switch
    {
        MicStatus.Connected => BarOnBrush,
        MicStatus.Problem => MicProblemBrush,
        _ => BarOffBrush,
    };

    public string MicStatusToolTip => MicStatus switch
    {
        MicStatus.Connected => "Mic connected — click to change",
        MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change",
        _ => "No mic connected — click to choose a microphone",
    };

    // ─── Game audio bar (desktop/game): visible only while a full-screen game
    // ─── is producing sound (IGameAudioDetector). Meter + mute + volume mirror
    // ─── the mic bar. ───

    /// <summary>True while the game audio bar should be shown: a full-screen game
    /// producing sound (IGameAudioDetector) OR background music playing (TASK 9)
    /// — the meter bounces during scene setup even with no game up.</summary>
    public bool IsGameAudioBarVisible => _gameAudioBarActive || IsMusicPlaying;

    /// <summary>True while the TRAX background track is playing.</summary>
    public bool IsMusicPlaying
    {
        get => _musicPlaying;
        private set
        {
            if (SetProperty(ref _musicPlaying, value))
            {
                OnPropertyChanged(nameof(TraxDotBrush));
                OnPropertyChanged(nameof(TraxToolTip));
                OnPropertyChanged(nameof(IsGameAudioBarVisible));
            }
        }
    }

    /// <summary>TRAX status dot: green = playing, yellow = loaded/stopped, red = no track.</summary>
    public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying
        ? BarOnBrush
        : _music != null && !string.IsNullOrWhiteSpace(_music.TrackPath)
            ? MicProblemBrush
            : BarOffBrush;

    public string TraxToolTip => _musicPlayer.TrackName != null
        ? $"TRAX: {_musicPlayer.TrackName} — click to {(IsMusicPlaying ? "pause" : "play")}"
        : "No track — right-click to choose background music";

    /// <summary>Left-click on TRAX: no track → pick one; otherwise toggle play/pause.</summary>
    public void TraxLeftClick()
    {
        if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
            PickTraxTrack();
        else if (IsMusicPlaying)
            PauseTrax();
        else
            PlayTrax();
    }

    /// <summary>Right-click on TRAX: always open the track picker (never the OS viewer).</summary>
    public void TraxRightClick() => PickTraxTrack();

    private void PickTraxTrack()
    {
        var dialog = new OpenFileDialog
        {
            Title = "Choose background music",
            Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*",
        };
        if (dialog.ShowDialog() != true)
            return;

        _music = new Music { TrackPath = dialog.FileName, IsEnabled = true };
        LoadTrax(play: true);
    }

    private void LoadTrax(bool play)
    {
        if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
            return;

        try
        {
            _musicPlayer.Load(_music.TrackPath);
            UpdateTraxLocalGain();
            _music.IsEnabled = true;
            if (play)
                _musicPlayer.Play();
        }
        catch (Exception ex)
        {
            AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
            _music = null;
        }
        RefreshTraxUi();
        ScheduleSave();
    }

    private void PlayTrax() => _musicPlayer.Play();

    private void PauseTrax() => _musicPlayer.Pause();

    private void OnTraxPlaybackEnded()
    {
        // Natural end loops inside the player; here only an explicit stop or a
        // device failure lands — flip the dot back to loaded/stopped.
        RefreshTraxUi();
    }

    private void RefreshTraxUi()
    {
        IsMusicPlaying = _musicPlayer.IsPlaying;
        OnPropertyChanged(nameof(TraxDotBrush));
        OnPropertyChanged(nameof(TraxToolTip));
    }

    /// <summary>Mirrors the desktop/game audio bar's volume and mute onto the
    /// music so the creator hears the controls work in the headphones (the
    /// track rides the loopback channel, so it must duck the same way locally).
    /// Called whenever the game volume/mute change and on TRAX load.</summary>
    private void UpdateTraxLocalGain()
    {
        _musicPlayer.LocalGain = GameMuted ? 0f : (float)GameAudioVolume;
    }

    /// <summary>Live desktop/game input level (0..1), fed by the mixer's loopback
    /// capture. Read by the game meter, scaled by GameAudioVolume.</summary>
    public double GameAudioLevel
    {
        get => _gameAudioLevel;
        set
        {
            if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1)))
            {
                OnPropertyChanged(nameof(GameMeterFillWidth));
                OnPropertyChanged(nameof(GameMeterBrush));
            }
        }
    }

    /// <summary>Displayed game meter level: 0 while muted; the volume position
    /// while the slider is dragged (or briefly after an unmute flash); otherwise
    /// the realtime live level scaled by volume.</summary>
    private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel) * GameAudioVolume);

    public double GameMeterFillWidth => GameMeterLevel * 288;

    public string GameMeterBrush => GameMeterLevel switch
    {
        < 0.6 => "#22c55e",
        < 0.8 => "#eab308",
        _ => "#ef4444",
    };

    public void SetGameVolumeAdjusting(bool adjusting)
    {
        if (adjusting)
            CancelGameVolumeFlash();
        if (SetProperty(ref _gameVolumeAdjusting, adjusting))
        {
            OnPropertyChanged(nameof(GameMeterFillWidth));
            OnPropertyChanged(nameof(GameMeterBrush));
        }
    }

    private void BeginGameVolumeFlash()
    {
        _gameVolumeFlash = true;
        OnPropertyChanged(nameof(GameMeterFillWidth));
        OnPropertyChanged(nameof(GameMeterBrush));
        _gameVolumeFlashTimer.Stop();
        _gameVolumeFlashTimer.Start();
    }

    private void EndGameVolumeFlash()
    {
        _gameVolumeFlashTimer.Stop();
        if (_gameVolumeFlash)
        {
            _gameVolumeFlash = false;
            OnPropertyChanged(nameof(GameMeterFillWidth));
            OnPropertyChanged(nameof(GameMeterBrush));
        }
    }

    private void CancelGameVolumeFlash()
    {
        _gameVolumeFlashTimer.Stop();
        _gameVolumeFlash = false;
    }

    /// <summary>Game audio gain (0..1, unity default). Running to 0 mutes; the
    /// prior level is remembered so the speaker button can restore it.</summary>
    public double GameAudioVolume
    {
        get => _gameVolume;
        set
        {
            var clamped = Math.Clamp(value, 0, 1);
            if (clamped == 0 && !_gameMuted)
                _gameVolumeBeforeMute ??= _gameVolume;
            if (SetProperty(ref _gameVolume, clamped))
            {
                var muted = clamped == 0;
                if (_gameMuted != muted)
                {
                    _gameMuted = muted;
                    OnPropertyChanged(nameof(GameMuted));
                    OnPropertyChanged(nameof(GameMuteText));
                }
                if (!muted)
                    _gameVolumeBeforeMute = null;
                OnPropertyChanged(nameof(GameMeterFillWidth));
                OnPropertyChanged(nameof(GameMeterBrush));
                UpdateTraxLocalGain();
            }
        }
    }

    /// <summary>Read-only: true whenever the volume is 0 (the slider and the
    /// speaker can never disagree).</summary>
    public bool GameMuted => _gameMuted;

    public string GameMuteText => GameMuted ? "Unmute" : "Mute";

    private void ToggleGameMute()
    {
        if (GameMuted)
        {
            GameAudioVolume = _gameVolumeBeforeMute ?? 1.0;
            BeginGameVolumeFlash();
        }
        else
        {
            _gameVolumeBeforeMute = GameAudioVolume;
            GameAudioVolume = 0;
        }
    }

    public bool IsOffline => StreamStatus == StreamStatus.Offline;
    public bool IsLive => StreamStatus == StreamStatus.Streaming;
    public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
    public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560";
    public string RecTextBrush => IsLive ? "#ffffff" : "#888888";
    public double RecDotOpacity => IsLive ? _recDotPulse : 0.55;
    public bool ShowStartStream => IsOffline;
    public bool ShowChatInactiveMessage => !IsLive;
    public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
    public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
    public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;

    /// <summary>The canonical scenes (SceneCatalog) not present right now — what the
    /// "+" button may re-add. Work with less, never more.</summary>
    public IEnumerable<string> MissingScenes
        => SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));

    /// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
    public bool ShowAddScene => MissingScenes.Any();

    /// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's backdrop.</summary>
    public bool CanChangeBackdrop => ActiveScene?.HasBackdrop == true;

    // Paid unlock flips this off (see ai.md "Monetization"). When disabled the
    // cadence timer is stopped and any active flash is hidden immediately.
    public bool BrandFlashEnabled
    {
        get => _brandFlashEnabled;
        set
        {
            if (!SetProperty(ref _brandFlashEnabled, value)) return;
            if (value)
            {
                if (IsLive) StartBrandFlashTimer();
            }
            else
            {
                _brandFlashTimer.Stop();
                _brandFlashOffTimer.Stop();
                BrandFlashActive = false;
            }
        }
    }

    public bool BrandFlashActive
    {
        get => _brandFlashActive;
        private set => SetProperty(ref _brandFlashActive, value);
    }

    private void UpdateActiveBackground()
    {
        var background = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
        ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
            ? ImageCache.Get(background.AssetId)
            : null;
        OnPropertyChanged(nameof(ShowPreviewPlaceholder));
    }

    private void UpdateBackdropImage()
    {
        var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
        BackdropImage = backdrop?.DisplaySource;
    }

    public StreamHealth CurrentHealth
    {
        get => _currentHealth;
        set => SetProperty(ref _currentHealth, value);
    }

    public string StreamTitle
    {
        get => _streamTitle;
        set => SetProperty(ref _streamTitle, value);
    }

    public string StreamDescription
    {
        get => _streamDescription;
        set => SetProperty(ref _streamDescription, value);
    }

    public string StreamVisibility
    {
        get => _streamVisibility;
        set
        {
            if (SetProperty(ref _streamVisibility, value))
            {
                OnPropertyChanged(nameof(RecDotBrush));
                OnPropertyChanged(nameof(RecTextBrush));
                OnPropertyChanged(nameof(IsLivePrivate));
            }
        }
    }

    public string WindowTitle
    {
        get => _windowTitle;
        set => SetProperty(ref _windowTitle, value);
    }

    public string TopBarBackground
    {
        get => _topBarBackground;
        set => SetProperty(ref _topBarBackground, value);
    }

    public string PreviewGlowBrush
    {
        get => _previewGlowBrush;
        set => SetProperty(ref _previewGlowBrush, value);
    }

    public Thickness PreviewGlowThickness
    {
        get => _previewGlowThickness;
        set => SetProperty(ref _previewGlowThickness, value);
    }

    public string LiveElapsedText
    {
        get => _liveElapsedText;
        set => SetProperty(ref _liveElapsedText, value);
    }

    // Overlay panels
    public bool IsSettingsOpen
    {
        get => _isSettingsOpen;
        private set => SetPanel(ref _isSettingsOpen, value);
    }

    public bool IsBugOpen
    {
        get => _isBugOpen;
        private set => SetPanel(ref _isBugOpen, value);
    }

    public bool IsFeatureOpen
    {
        get => _isFeatureOpen;
        private set => SetPanel(ref _isFeatureOpen, value);
    }

    public bool IsAboutOpen
    {
        get => _isAboutOpen;
        private set => SetPanel(ref _isAboutOpen, value);
    }

    /// <summary>About hub sub-view: the full licensing/legal texts panel
    /// (in-app scrolling — no Notepad, no browser tab).</summary>
    public bool IsLicensesOpen
    {
        get => _isLicensesOpen;
        set => SetProperty(ref _isLicensesOpen, value);
    }

    public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;

    public string OverlayTitle
    {
        get => _overlayTitle;
        private set => SetProperty(ref _overlayTitle, value);
    }

    public string DefaultStreamTitle
    {
        get => _defaultStreamTitle;
        set => SetProperty(ref _defaultStreamTitle, value);
    }

    public string DefaultStreamDescription
    {
        get => _defaultStreamDescription;
        set => SetProperty(ref _defaultStreamDescription, value);
    }

    // ─── Resolution quality dropdown (bottom bar) ───
    // Tiers offered to the creator before going live. First = default. The
    // dropdown is disabled while live because YouTube stream resolution is
    // immutable after stream creation (see TASKS.md compliance notes).

    // The composition master frame. Every tier is an output rect + target
    // resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
    // use the full frame; the vertical tier uses a centered 9:16 window and
    // the preview dims the cropped strips (semi-crop).
    private const double MasterFrameWidth = 1920;
    private const double MasterFrameHeight = 1080;

    // Webcam size safeguard: no more than half the frame in any dimension
    // (960x540 over the 1920x1080 master), and no less than 10% of it
    // (192x108). Enforced at resize and on layout load. The Chat scene is
    // exempt from the per-dimension half — its webcam may take half the
    // screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
    // matched by canonical name (SceneCatalog.IsChat).
    public const double WebcamMaxWidth = 960;
    public const double WebcamMaxHeight = 540;
    public const double WebcamChatMaxWidth = 1358;
    public const double WebcamChatMaxHeight = 764;
    public const double WebcamMinWidth = MasterFrameWidth * 0.1;
    public const double WebcamMinHeight = MasterFrameHeight * 0.1;

    public static double MaxWebcamWidthFor(string? sceneName)
        => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;

    public static double MaxWebcamHeightFor(string? sceneName)
        => SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;

    internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
    {
        var maxWidth = MaxWebcamWidthFor(sceneName);
        var maxHeight = MaxWebcamHeightFor(sceneName);
        var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
        if (scale < 1)
        {
            config.Width = Math.Round(config.Width * scale);
            config.Height = Math.Round(config.Height * scale);
            return;
        }

        var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
        if (minScale > 1)
        {
            config.Width = Math.Round(config.Width * minScale);
            config.Height = Math.Round(config.Height * minScale);
        }
    }

    // One-time heal for layouts saved before the rect dims were persisted (v3→v4):
    // a Traditional webcam that ended up square (Round resize then reload lost the
    // pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
    // a square bounding box is correct there — and an explicit pre-Round rect wins.
    internal static void HealLegacySquareRect(WebcamSceneConfig config)
    {
        if (config.ClipShape != ClipShape.Traditional) return;
        if (config.RectWidth != null || config.RectHeight != null) return;
        if (Math.Abs(config.Width - config.Height) >= 1) return;
        config.Width = Math.Round(config.Height * 16.0 / 9.0);
    }

    public QualityOption[] QualityOptions { get; } =
    {
        new("1080p60", 60, 8.0, 1920, 1080),
        new("1080p30", 30, 8.0, 1920, 1080),
        new("720p60", 60, 6.0, 1280, 720),
        new("720p30", 30, 6.0, 1280, 720),
        new("Vertical 1080p60", 60, 8.0, 1080, 1920),
    };

    public string ResolutionHelp { get; } =
        "Pick the highest resolution your upload bandwidth can comfortably sustain. " +
        "Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
        "then choose a tier your upload comfortably exceeds. 1080p60 is the default.";

    private QualityOption _selectedQuality;
    public QualityOption SelectedQuality
    {
        get => _selectedQuality;
        set
        {
            if (SetProperty(ref _selectedQuality, value))
                ApplyStreamQuality(value);
        }
    }

    // Output frame of the selected tier over the 1920x1080 master. The strips
    // outside it are dimmed in the preview so the cropped area stays visible.
    public double OutputRectX { get; private set; }
    public double OutputRectY { get; private set; }
    public double OutputRectWidth { get; private set; }
    public double OutputRectHeight { get; private set; }
    public bool IsOutputCropped { get; private set; }
    public string ResolutionBadgeText { get; private set; } = string.Empty;
    public ObservableCollection<Rect> DimRects { get; } = new();

    private void ApplyStreamQuality(QualityOption quality)
    {
        var health = CurrentHealth;
        health.CurrentBitrate = quality.Bitrate;
        health.FPS = quality.Fps;
        OnPropertyChanged(nameof(CurrentHealth));

        var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
        var rectW = quality.Width * scale;
        var rectH = quality.Height * scale;
        var rectX = (MasterFrameWidth - rectW) / 2;
        var rectY = (MasterFrameHeight - rectH) / 2;

        OutputRectX = rectX;
        OutputRectY = rectY;
        OutputRectWidth = rectW;
        OutputRectHeight = rectH;

        DimRects.Clear();
        if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
        if (rectX + rectW < MasterFrameWidth)
            DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
        if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
        if (rectY + rectH < MasterFrameHeight)
            DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));

        IsOutputCropped = DimRects.Count > 0;
        ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";

        OnPropertyChanged(nameof(OutputRectX));
        OnPropertyChanged(nameof(OutputRectY));
        OnPropertyChanged(nameof(OutputRectWidth));
        OnPropertyChanged(nameof(OutputRectHeight));
        OnPropertyChanged(nameof(IsOutputCropped));
        OnPropertyChanged(nameof(ResolutionBadgeText));
    }

    public string BugReportText
    {
        get => _bugReportText;
        set => SetProperty(ref _bugReportText, value);
    }

    public string BugReportEmail
    {
        get => _bugReportEmail;
        set => SetProperty(ref _bugReportEmail, value);
    }

    public string FeatureRequestText
    {
        get => _featureRequestText;
        set => SetProperty(ref _featureRequestText, value);
    }

    public string FeatureRequestEmail
    {
        get => _featureRequestEmail;
        set => SetProperty(ref _featureRequestEmail, value);
    }

    // Commands
    public ICommand AddSceneCommand { get; }
    public ICommand AddSourceCommand { get; }
    public ICommand AddWebcamCommand { get; }
    public ICommand AddImageCommand { get; }
    public ICommand RemoveSourceCommand { get; }
    public ICommand EditElementCommand { get; }
    public ICommand ToggleElementVisibilityCommand { get; }
    public ICommand ChangeWebcamCommand { get; }
    public ICommand ShowWebcamCommand { get; }
    public ICommand ChangeCaptureCommand { get; }
    public ICommand RefreshCaptureCommand { get; }
    public ICommand SetBackdropDisplayCommand { get; }
    public ICommand ToggleMicMuteCommand { get; }
    public ICommand ToggleGameMuteCommand { get; }
    public ICommand OpenMicPickerCommand { get; }
    public ICommand OpenSocialDialogCommand { get; }
    public ICommand StartStreamCommand { get; }
    public ICommand EndStreamCommand { get; }
    public ICommand OpenSettingsCommand { get; }
    public ICommand OpenBugCommand { get; }
    public ICommand OpenFeatureCommand { get; }
    public ICommand OpenAboutCommand { get; }
    public ICommand CloseOverlayCommand { get; }
    public ICommand SubmitBugCommand { get; }
    public ICommand SubmitFeatureCommand { get; }
    public ICommand OpenChannelCommand { get; }
    public ICommand OpenMastodonCommand { get; }
    public ICommand OpenCoffeeCommand { get; }
    public ICommand OpenPremiumCommand { get; }
    public ICommand OpenLicensesCommand { get; }
    public ICommand SaveLayoutCommand { get; }
    public ICommand SaveLayoutAsCommand { get; }
    public ICommand OpenLayoutCommand { get; }

    public MainViewModel()
    {
        AppLog.Write("MainViewModel ctor begin");
        _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
            sessionChanged: ch => TokenStore.Save(ch));
        _youtubeStream = new YouTubeStreamService(_youtubeAuth);
        _youtubeChat = new YouTubeChatService(_youtubeAuth);
        AppLog.Write("MainViewModel ctor: services created");

        _selectedQuality = QualityOptions[0];
        ApplyStreamQuality(_selectedQuality);

        _youtubeChat.MessageReceived += OnChatMessageReceived;

        _liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
        _liveTimer.Tick += OnLiveTimerTick;

        _volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
        _volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();

        _gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
        _gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();

        _saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
        _saveDebounce.Tick += (_, _) => SaveLayoutNow();

        _brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
        _brandFlashTimer.Tick += OnBrandFlashTimerTick;
        _brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
        _brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };

        Scenes.CollectionChanged += OnScenesChanged;

        AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
        AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
        AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToActiveSceneAsync());
        AddImageCommand = new RelayCommand(_ => AddImage());
        RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
        EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
        ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
        ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
        ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
        OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
        ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
        RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
        SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
        ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
        ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
        OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
        OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
        OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
        OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
        OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
        CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
        SubmitBugCommand = new RelayCommand(_ => SubmitBug());
        SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
        OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
        OpenMastodonCommand = new RelayCommand(_ => OpenUrl(MastodonUrl));
        OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
        OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
        OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
        StartStreamCommand = new RelayCommand(_ => BeginGoLive());
        EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
        SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
        SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
        OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());

        _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
        _activeLayoutPath = _layoutStore.ActivePath;

        // Restore the last picked mic so the mixer's first capture attaches to
        // the same, already-vetted device (green dot) or reports it missing
        // (yellow) instead of silently falling back to the default.
        MicSourceName = _layoutStore.LoadMicSourceName();

        _cameraEnumerator = new MediaCaptureCameraEnumerator();
        _cameraManager = new CameraManager(
            _cameraEnumerator,
            id => new MediaCaptureFrameSource(id),
            System.Windows.Application.Current?.Dispatcher);
        _cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
        _cameraManager.CameraFailed += OnCameraFailed;

        _microphoneEnumerator = new WinRtMicrophoneEnumerator();

        // The mixer consumes these Func seams live each mix tick, so the volume
        // sliders and mute buttons are stream-honest (they also update the
        // meters' display). Before this wiring the gains defaulted to unity and
        // the controls were decorative.
        _gainProvider = new AudioGainProvider(
            () => MicMuted,
            () => MicVolume,
            () => GameMuted,
            () => GameAudioVolume);
        _audioMixer = new AudioMixer(
            new WasapiMicAudioSource(() => MicSourceName),
            new WasapiLoopbackAudioSource(),
            message => AppLog.Write(message),
            micGain: _gainProvider.MicGain,
            loopbackGain: _gainProvider.LoopbackGain);
        _audioMixer.MicLevelChanged += OnMicLevelChanged;
        _audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
        _audioMixer.MicConnected += OnMicConnected;
        _audioMixer.MicFailed += OnMicFailed;
        _musicPlayer.PlaybackEnded += OnTraxPlaybackEnded;
        _ = StartMicCaptureAsync();

        _fullScreenDetector = new Win32FullScreenDetector();
        foreach (var display in _fullScreenDetector.GetDisplays())
            Displays.Add(display);

        _gameAudioDetector = new GameAudioDetector(
            () => _fullScreenDetector.GetForegroundFullScreenMonitorIndex(),
            () => (float)GameAudioLevel);
        _gameAudioDetector.IsGameAudioActiveChanged += OnGameAudioActiveChanged;
        _gameAudioTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
        _gameAudioTimer.Tick += OnGameAudioPollTick;
        _gameAudioTimer.Start();
        _screenCaptureFactory = new ScreenCaptureSourceFactory(
            () => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
        _screenCaptureManager = new ScreenCaptureManager(
            _screenCaptureFactory.Resolve,
            System.Windows.Application.Current?.Dispatcher);
        _screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
        _screenCaptureManager.CaptureFailed += (key, message) =>
            AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");

        _rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL
        _framePump = new FramePump(
            sceneProvider: () => ActiveScene,
            frameResolver: ResolveOutputFrame,
            compositorOptions: BuildCompositorOptions,
            encoderOptions: BuildEncoderOptions,
            encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
            log: message => AppLog.Write(message),
            socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom));
        _framePump.Failed += OnFramePumpFailed;
        _framePump.HealthUpdated += OnFramePumpHealthUpdated;

        LoadLayout();
        _ = LoadSavedSessionAsync();
        AppLog.Write("MainViewModel ctor end");
    }

    // Restores the DPAPI-saved OAuth session so sign-in survives restarts.
    // Best-effort: refresh a near-expiry access token; a session that can no
    // longer refresh is discarded and the user signs in again.
    private async Task LoadSavedSessionAsync()
    {
        try
        {
            var saved = TokenStore.Load();
            if (saved == null) return;

            _youtubeAuth.SetSession(saved);
            if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
            {
                var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
                if (!refreshed)
                {
                    TokenStore.Clear();
                    AppLog.Write("Saved session could not be refreshed; signed out");
                    return;
                }
            }

            IsConnected = true;
            SyncConnectedAccount();
        }
        catch (Exception ex)
        {
            AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
        }
    }

    private void SyncConnectedAccount()
    {
        var channel = _youtubeAuth.CurrentChannel;
        AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty;
        AccountDisplayName = channel?.DisplayName ?? string.Empty;
    }

    private static string DefaultLayoutPath => Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
        "ytLlive",
        "ytLlive.db");

    // Test seam: a real MainWindow test must never read or write the user's
    // real layout DB (it would persist test sources over real ones). Tests set
    // this to a temp path before constructing MainWindow and reset it after.
    internal static string? LayoutPathOverride { get; set; }

    private void LoadLayout()
    {
        _isLoading = true;
        try
        {
            AppLog.Write("LoadLayout begin");
            var scenes = _layoutStore.Load();
            Scenes.Clear();
            foreach (var scene in scenes)
                Scenes.Add(scene);

            if (Scenes.Count == 0)
                foreach (var name in SceneCatalog.All)
                    AddScene(name);

            // The live backdrop belongs to Live only; a pre-policy DB may have
            // backdrops lingering in other scenes — drop them, then
            // ReacquireScreenCaptures heals Live's.
            EnforceBackdropPolicy(Scenes);

            foreach (var scene in Scenes)
                foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
                {
                    ClampWebcamToBounds(config, scene.Name);
                    HealLegacySquareRect(config);
                }
            AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
        }
        finally
        {
            _isLoading = false;
        }
        ActiveScene = Scenes.FirstOrDefault();
        UpdateActiveBackground();
        UpdateBackdropImage();
        ReacquireWebcam();
        ReacquireScreenCaptures();
        _socials = _layoutStore.Socials;
        RenderSocialBarFrame();
        HealFediverseSoftwareInBackground();
        OnPropertyChanged(nameof(HasSocials));
        OnPropertyChanged(nameof(SocialBarVisible));
        OnPropertyChanged(nameof(SocialBarDotBrush));
        _music = _layoutStore.Music;
        LoadTrax(play: false);
        ScheduleSave();
        AppLog.Write("LoadLayout end");
    }

    // After a layout load / file open: re-point the webcam identity, stop the
    // previous device if it changed, and acquire the current one once per scene
    // that uses it (CameraManager refcounts by DeviceId — one camera session).
    private void ReacquireWebcam()
    {
        var previousDevice = _webcam?.DeviceId;
        _webcam = _layoutStore.Webcam;
        var newDevice = _webcam?.DeviceId;

        if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
            _ = _cameraManager.ReleaseAllAsync(previousDevice);

        OnPropertyChanged(nameof(CanChangeWebcam));
        OnPropertyChanged(nameof(CanAddWebcamToActiveScene));

        if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
        if (previousDevice != newDevice)
            foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
                _ = _cameraManager.AcquireAsync(newDevice);

        // A session already running (same device, or one that produced a first
        // frame) has its shared bitmap; freshly loaded configs must adopt it here,
        // because PreviewBitmapChanged never re-fires for an existing bitmap.
        if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
            foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
                config.VideoImageSource = running;
    }

    // CameraManager creates the shared WriteableBitmap on the UI thread at the
    // device's frame size; every scene's webcam config picks it up from here. A
    // bitmap means a real frame arrived — the camera is provably alive.
    private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
    {
        if (_webcam?.DeviceId != deviceId) return;
        WebcamError = null;
        foreach (var scene in Scenes)
            foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
                config.VideoImageSource = bitmap;
    }

    // The camera failed to start or died asynchronously (in use, offline, locked,
    // no frames within the proof timeout) — surface it instead of a silent box.
    private void OnCameraFailed(string deviceId, string message)
    {
        if (_webcam?.DeviceId != deviceId) return;
        WebcamError = $"Webcam offline: {message}";
    }

    // ─── Screen backdrop capture (live desktop/game) ───

    private const string MonitorKeyPrefix = "monitor:";

    /// <summary>Every backdrop-enabled scene has exactly one backdrop, kept at index 0 (bottom layer).
    /// Returns null for a scene with <see cref="Scene.HasBackdrop"/> = false.</summary>
    internal static Source? EnsureBackdrop(Scene scene)
    {
        if (!scene.HasBackdrop) return null;
        var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
        if (backdrop != null) return backdrop;

        backdrop = new Source
        {
            Name = "Backdrop",
            Type = SourceType.DisplayCapture,
            IsBackdrop = true,
            IsEnabled = true,
            X = 0,
            Y = 0,
            Width = MasterFrameWidth,
            Height = MasterFrameHeight,
        };
        scene.Elements.Insert(0, backdrop);
        return backdrop;
    }

    // The backdrop belongs to the Live scene alone. Runs after every layout
    // load: the flag is normalized by scene name and any backdrop lingering in a
    // non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
    // re-heals Live's backdrop right after.
    internal static void EnforceBackdropPolicy(IEnumerable<Scene> scenes)
    {
        foreach (var scene in scenes)
        {
            scene.HasBackdrop = SceneCatalog.HasBackdrop(scene.Name);
            if (scene.HasBackdrop) continue;
            var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
            if (backdrop != null)
                scene.Elements.Remove(backdrop);
        }
    }

    private IEnumerable<Source> AllBackdrops()
        => Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));

    // Full-screen game on its monitor, else the primary display. Called at launch
    // (from ReacquireScreenCaptures, before the window steals focus) and on
    // focus regain (RefreshBackdropAutoCapture).
    private string ResolveAutoCaptureKey()
        => $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";

    // After a layout load / file open: make sure every backdrop-enabled scene
    // has a backdrop, give any backdrop without a persisted key the auto-detected
    // one, then acquire one capture per unique backdrop key. ScreenCaptureManager
    // refcounts by key, so every scene pointing at the same monitor shares one
    // session. Captures no longer referenced by any scene are released.
    private void ReacquireScreenCaptures()
    {
        foreach (var scene in Scenes)
            EnsureBackdrop(scene);

        var auto = ResolveAutoCaptureKey();
        foreach (var backdrop in AllBackdrops())
            if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
                backdrop.CaptureKey = auto;

        var keys = AllBackdrops()
            .Select(b => b.CaptureKey!)
            .Where(k => !string.IsNullOrWhiteSpace(k))
            .ToHashSet();

        // A layout reload replaces the scenes; stop captures that are no longer
        // referenced (mirrors ReacquireWebcam's device swap).
        foreach (var stale in _liveCaptureKeys.Except(keys))
            _ = _screenCaptureManager.ReleaseAllAsync(stale);
        _liveCaptureKeys = keys;

        foreach (var key in keys)
            _ = _screenCaptureManager.AcquireAsync(key);
    }

    // ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
    // every backdrop pointing at that key picks it up.
    private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
    {
        var activeBackdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
        if (activeBackdrop?.CaptureKey == key)
            BackdropImage = bitmap;

        foreach (var backdrop in AllBackdrops().Where(b => b.CaptureKey == key))
            backdrop.VideoImageSource = bitmap;
    }

    // One-shot foreground snapshot ~250ms after we lose focus, so the next
    // Activated re-detect can see the full-screen game the user switched to.
    // A single sample per deactivation — not a session listener or a poller.
    public void NoteBackgroundWindow()
    {
        _deactivateCts?.Cancel();
        _deactivateCts = new CancellationTokenSource();
        var token = _deactivateCts.Token;
        _ = Task.Run(async () =>
        {
            try
            {
                await Task.Delay(250, token);
                var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
                if (!token.IsCancellationRequested)
                    _lastForegroundFullScreenMonitor = monitor;
            }
            catch (OperationCanceledException) { }
        });
    }

    // Re-runs full-screen detection at launch and when the app regains focus.
    // A null detection (no full-screen foreground window — our app, the desktop,
    // a normal window) leaves the current capture alone.
    public void RefreshBackdropAutoCapture()
    {
        var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
        _lastForegroundFullScreenMonitor = null;
        if (detected == null) return;
        _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
    }

    private async Task RedesignateBackdropAsync(string newKey)
    {
        var oldKeys = AllBackdrops()
            .Select(b => b.CaptureKey!)
            .Where(k => !string.IsNullOrWhiteSpace(k))
            .ToHashSet();

        foreach (var backdrop in AllBackdrops())
            backdrop.CaptureKey = newKey;

        foreach (var oldKey in oldKeys.Where(k => k != newKey))
            await _screenCaptureManager.ReleaseAllAsync(oldKey);
        if (!oldKeys.Contains(newKey))
            await _screenCaptureManager.AcquireAsync(newKey);
        _liveCaptureKeys = new HashSet<string> { newKey };
        UpdateBackdropImage();
        ScheduleSave();
    }

    // "Change Capture…": the OS GraphicsCapturePicker designates the target.
    // Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
    // back to auto-detection.
    public async Task ChangeBackdropCaptureAsync()
    {
        var key = await _screenCaptureFactory.PickAsync();
        if (key == null) return;
        await RedesignateBackdropAsync(key);
    }

    // In-app display picker: point every backdrop at a specific monitor.
    private void SetBackdropCapture(DisplayInfo? display)
    {
        if (display == null) return;
        _ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{display.Index}");
    }

    public void Shutdown()
    {
        _saveDebounce?.Stop();
        SaveLayoutNow();
        _gameAudioTimer.Stop();
        _audioMixer.Dispose();
        _framePump.Dispose();
        _cameraManager.Dispose();
        _screenCaptureManager.Dispose();
        _layoutStore.Dispose();
        _musicPlayer.Dispose();
    }

    public void SaveLayoutNow()
    {
        _saveDebounce?.Stop();
        try
        {
            _layoutStore.Save(Scenes, _webcam, _socials, _music);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Layout save failed: {ex.Message}");
        }
    }

    private void SaveLayoutAs()
    {
        var dialog = new SaveFileDialog
        {
            Title = "Save Layout As",
            Filter = "ytLlive layout (*.yll)|*.yll|All files (*.*)|*.*",
            DefaultExt = ".yll",
            FileName = "my-layout.yll",
        };
        if (dialog.ShowDialog() != true) return;

        _layoutStore.Dispose();
        _layoutStore = new LayoutStore(dialog.FileName);
        _activeLayoutPath = dialog.FileName;
        SaveLayoutNow();
    }

    private void OpenLayoutFile()
    {
        var dialog = new OpenFileDialog
        {
            Title = "Open Layout",
            Filter = "ytLlive layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*",
        };
        if (dialog.ShowDialog() != true) return;

        _layoutStore.Dispose();
        _layoutStore = new LayoutStore(dialog.FileName);
        _activeLayoutPath = dialog.FileName;
        LoadLayout();
    }

    // ─── Auto-save wiring ───
    private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
            foreach (Scene scene in e.NewItems)
                WireScene(scene);
        if (e.OldItems != null)
            foreach (Scene scene in e.OldItems)
                UnwireScene(scene);
        OnPropertyChanged(nameof(MissingScenes));
        OnPropertyChanged(nameof(ShowAddScene));
        ScheduleSave();
    }

    private void WireScene(Scene scene)
    {
        scene.PropertyChanged += OnScenePropertyChanged;
        scene.Elements.CollectionChanged += OnElementsChanged;
    }

    private void UnwireScene(Scene scene)
    {
        scene.PropertyChanged -= OnScenePropertyChanged;
        scene.Elements.CollectionChanged -= OnElementsChanged;
    }

    private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        // A rename can drop a canonical scene in/out of MissingScenes.
        OnPropertyChanged(nameof(MissingScenes));
        OnPropertyChanged(nameof(ShowAddScene));
        ScheduleSave();
    }

    private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
            foreach (SceneElement element in e.NewItems)
                element.PropertyChanged += OnElementPropertyChanged;
        if (e.OldItems != null)
            foreach (SceneElement element in e.OldItems)
                element.PropertyChanged -= OnElementPropertyChanged;
        OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
        ScheduleSave();
    }

    private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
    {
        if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
            OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
        ScheduleSave();
    }

    private void ScheduleSave()
    {
        if (_isLoading || _saveDebounce == null) return;
        _saveDebounce.Stop();
        _saveDebounce.Start();
    }

    // Adds a canonical scene by name — only when it's actually missing. The
    // backdrop flag follows the policy (Live yes, everyone else no).
    private void AddScene(string name)
    {
        if (!SceneCatalog.IsCanonical(name)) return;
        if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
        var scene = new Scene
        {
            Name = name.Trim(),
            IsChatScene = SceneCatalog.IsChat(name),
            HasBackdrop = SceneCatalog.HasBackdrop(name),
        };
        EnsureBackdrop(scene);
        Scenes.Add(scene);
        ActiveScene = scene;
    }

    private void BeginEditElement(SceneElement? element)
    {
        if (element == null) return;
        element.IsEditing = true;
    }

    private void ToggleElementVisibility(SceneElement? element)
    {
        if (element == null) return;
        element.IsVisible = !element.IsVisible;
    }

    private void AddSource(object? parameter)
    {
        var scene = ActiveScene;
        if (scene == null) return;

        var sourceType = parameter is SourceType typed
            ? typed
            : Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
        var baseName = sourceType switch
        {
            SourceType.DisplayCapture => "Screen",
            SourceType.WindowCapture => "Window",
            SourceType.Background => "Background",
            SourceType.Image => "Image",
            SourceType.TextOverlay => "Text",
            _ => "Source",
        };

        if (sourceType == SourceType.Background)
        {
            var bytes = PickImageBytes("Choose a backdrop image");
            if (bytes == null) return;
            var assetId = AddAsset(bytes);
            if (assetId == null) return;

            var existing = scene.Elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
            if (existing != null)
            {
                existing.AssetId = assetId;
                UpdateActiveBackground();
                return;
            }

            scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
            OnPropertyChanged(nameof(ShowEmptySceneHint));
            OnPropertyChanged(nameof(ShowSourcesEmptyHint));
            UpdateActiveBackground();
            return;
        }

        scene.Elements.Add(new Source { Name = NextSourceName(scene, baseName), Type = sourceType });
        OnPropertyChanged(nameof(ShowEmptySceneHint));
        OnPropertyChanged(nameof(ShowSourcesEmptyHint));
        UpdateActiveBackground();
    }

    // Duplicate resource names get an incrementing suffix with no space: Image,
    // Image2, Image3… The next free number is derived from the names actually in
    // the scene, so deleting a middle resource never collides with a survivor.
    private static string NextSourceName(Scene scene, string baseName)
    {
        var taken = scene.Elements.OfType<Source>()
            .Select(s => s.Name)
            .Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase)
                || (n.Length > baseName.Length
                    && n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase)
                    && int.TryParse(n.Substring(baseName.Length), out _)))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);
        if (!taken.Contains(baseName)) return baseName;
        for (var i = 2; ; i++)
            if (!taken.Contains($"{baseName}{i}"))
                return $"{baseName}{i}";
    }

    // Adds the webcam to the active scene. The creator ALWAYS picks from the
    // cameras Windows has registered — never silently resurrects the previous
    // camera (which is what happened after deleting one scene's webcam while
    // another scene still used it). Picking a different camera than the current
    // app-wide one swaps it everywhere, so the single-identity model stays honest;
    // each scene's placement config is independent (webcam.{scene}.config).
    private async Task AddWebcamToActiveSceneAsync()
    {
        var scene = ActiveScene;
        if (scene == null || scene.WebcamConfig != null) return;

        var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
        {
            Owner = Application.Current.MainWindow
        };
        if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
        var device = dialog.PickedDevice;

        if (_webcam == null)
        {
            _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
            OnPropertyChanged(nameof(CanChangeWebcam));
        }
        else if (_webcam.DeviceId != device.Id)
        {
            await SwapWebcamIdentityAsync(device);
        }

        var config = new WebcamSceneConfig
        {
            WebcamId = _webcam.Id,
            Name = _webcam.Name,
            Width = 480,
            Height = 270,
            X = MasterFrameWidth - 480 - 32,
            Y = MasterFrameHeight - 270 - 32,
        };

        scene.Elements.Add(config);
        SelectedElement = config;
        OnPropertyChanged(nameof(ShowEmptySceneHint));
        OnPropertyChanged(nameof(ShowSourcesEmptyHint));
        UpdateActiveBackground();

        // PreviewBitmapChanged fires only on the camera's first frame, so a config
        // added while the session is already running must pick up the shared bitmap
        // directly (it's written in place from then on).
        if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
            config.VideoImageSource = running;

        var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
        if (!started)
        {
            MessageBox.Show(
                WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
                "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
        }
    }

    // Swaps the device on the app-wide webcam identity. The old device is stopped
    // unconditionally; each scene that uses the webcam re-acquires the new one so
    // the per-config refcount stays honest. Only called when the device differs.
    private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
    {
        var oldDevice = _webcam!.DeviceId;
        _webcam.DeviceId = device.Id;
        _webcam.Name = device.DisplayName;
        ScheduleSave();

        if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
            await _cameraManager.ReleaseAllAsync(oldDevice);

        foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
        {
            var started = await _cameraManager.AcquireAsync(device.Id);
            if (!started)
            {
                MessageBox.Show(
                    WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
                    "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
                break;
            }
        }
    }

    // "Change Webcam…" from the webcam's context menu: picker, then swap the
    // app-wide identity if a different device was chosen.
    private async Task ChangeWebcamAsync()
    {
        if (_webcam == null) return;

        var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
        {
            Owner = Application.Current.MainWindow
        };
        if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
        if (dialog.PickedDevice.Id == _webcam.DeviceId) return;

        await SwapWebcamIdentityAsync(dialog.PickedDevice);
    }

    // "Show Webcam" from a right-click on the empty preview. Unhides this scene's
    // existing (hidden) config — the config row survives Hide in this scene — or
    // adds the webcam here for the first time (which opens the camera picker).
    private void ShowWebcamInActiveScene()
    {
        var scene = ActiveScene;
        if (scene == null) return;

        var config = scene.WebcamConfig;
        if (config != null)
        {
            config.IsVisible = true;
            SelectedElement = config;
            return;
        }

        _ = AddWebcamToActiveSceneAsync();
    }

    private void AddImage()
    {
        var scene = ActiveScene;
        if (scene == null) return;

        var candidates = BuildImageCandidates();
        if (candidates.Count == 0)
        {
            var bytes = PickImageBytes("Choose an image");
            if (bytes != null) AddImageSource(bytes);
            return;
        }

        var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates))
        {
            Owner = Application.Current.MainWindow
        };
        if (dialog.ShowDialog() != true) return;

        if (dialog.WantsNew)
        {
            var bytes = PickImageBytes("Choose an image");
            if (bytes != null) AddImageSource(bytes);
        }
        else
        {
            AddReusedImage(dialog.PickedAssetId!);
        }
    }

    private List<ReuseImageCandidate> BuildImageCandidates()
    {
        var candidates = new List<ReuseImageCandidate>();
        foreach (var scene in Scenes)
        foreach (var source in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
        {
            candidates.Add(new ReuseImageCandidate
            {
                SceneName = scene.Name,
                SourceName = source.Name,
                AssetId = source.AssetId!
            });
        }
        return candidates;
    }

    private static byte[]? PickImageBytes(string title)
    {
        var dialog = new OpenFileDialog
        {
            Title = title,
            Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*"
        };
        if (dialog.ShowDialog() != true) return null;
        try
        {
            return File.ReadAllBytes(dialog.FileName);
        }
        catch (Exception ex)
        {
            MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive",
                MessageBoxButton.OK, MessageBoxImage.Warning);
            return null;
        }
    }

    private string? AddAsset(byte[] bytes)
    {
        try
        {
            var image = ImageCache.FromBytes(bytes);
            var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0);
            if (id != null && image != null) ImageCache.Put(id, image);
            return id;
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Asset store failed: {ex.Message}");
            return null;
        }
    }

    private void AddImageSource(byte[] bytes)
    {
        if (bytes.Length == 0) return;
        var assetId = AddAsset(bytes);
        if (assetId != null) AddReusedImage(assetId);
    }

    private void AddReusedImage(string assetId)
    {
        var scene = ActiveScene;
        if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;

        var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId };

        var image = ImageCache.Get(assetId);
        if (image != null)
        {
            var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight);
            source.Width = image.PixelWidth * scale;
            source.Height = image.PixelHeight * scale;
            source.X = (1920 - source.Width) / 2;
            source.Y = (1080 - source.Height) / 2;
        }

        scene.Elements.Add(source);
        SelectedElement = source;
        OnPropertyChanged(nameof(ShowEmptySceneHint));
        OnPropertyChanged(nameof(ShowSourcesEmptyHint));
        UpdateActiveBackground();
    }

    // Removes an element from the active scene. For a webcam config this drops the
    // scene's usage and releases one camera reference (CameraManager stops the
    // session when the last using scene lets go). When the last config anywhere is
    // removed, the webcam identity is cleared too — re-adding opens the picker
    // again instead of silently resurrecting the old camera.
    private void RemoveElement(SceneElement? element)
    {
        var scene = ActiveScene;
        if (scene == null || element == null) return;
        if (element is Source { IsBackdrop: true }) return;
        if (element is WebcamSceneConfig && _webcam != null)
        {
            if (SelectedElement == element)
                SelectedElement = null;
            _ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
        }
        scene.Elements.Remove(element);

        if (element is WebcamSceneConfig && _webcam != null
            && !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
        {
            _webcam = null;
            OnPropertyChanged(nameof(CanChangeWebcam));
            OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
            OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
        }

        OnPropertyChanged(nameof(ShowEmptySceneHint));
        OnPropertyChanged(nameof(ShowSourcesEmptyHint));
        UpdateActiveBackground();
    }

    private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
    {
        if (SetProperty(ref field, value, propertyName))
            OnPropertyChanged(nameof(IsAnyOverlayOpen));
    }

    private void ShowOverlay(string? panel = null, string title = "")
    {
        IsSettingsOpen = panel == nameof(IsSettingsOpen);
        IsBugOpen = panel == nameof(IsBugOpen);
        IsFeatureOpen = panel == nameof(IsFeatureOpen);
        IsAboutOpen = panel == nameof(IsAboutOpen);
        OverlayTitle = title;
        if (panel != nameof(IsAboutOpen))
            IsLicensesOpen = false;
    }

    /// <summary>Shows the in-app licensing panel, loading the full texts from
    /// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer).
    /// Falls back to a "not found" note if the file is missing so the panel
    /// never hangs on nothing.</summary>
    private void ShowLicenses()
    {
        if (string.IsNullOrEmpty(LicensesText))
        {
            try
            {
                var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
                LicensesText = File.Exists(path)
                    ? File.ReadAllText(path)
                    : "THIRD-PARTY-NOTICES.txt not found next to the executable.";
            }
            catch (Exception ex)
            {
                LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}";
            }
        }
        IsLicensesOpen = true;
    }

    private void SubmitBug()
    {
        var body = string.Join(Environment.NewLine,
            BugReportText.Trim(),
            string.Empty,
            $"ytLlive {AppVersionLabel}",
            string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}");
        ComposeEmail("[ytLlive Bug Report]", body);
        ShowOverlay();
    }

    private void SubmitFeature()
    {
        var body = string.Join(Environment.NewLine,
            FeatureRequestText.Trim(),
            string.Empty,
            $"ytLlive {AppVersionLabel}",
            string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}");
        ComposeEmail("[ytLlive Feature Request]", body);
        ShowOverlay();
    }

    private static void ComposeEmail(string subject, string body)
    {
        var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}";
        OpenUrl(uri);
    }

    private static void OpenUrl(string url)
    {
        Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
    }

    // Runs the OAuth flow (browser + loopback callback) on a background thread.
    // The resulting channel is returned to the caller and persisted via the
    // auth service's sessionChanged hook.
    private async Task<YouTubeChannel?> SignInAsync()
    {
        try
        {
            var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync());
            if (channel == null)
            {
                MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
                    MessageBoxButton.OK, MessageBoxImage.Warning);
                return null;
            }

            IsConnected = true;
            SyncConnectedAccount();
            return channel;
        }
        catch (Exception ex)
        {
            MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
                MessageBoxButton.OK, MessageBoxImage.Error);
            return null;
        }
    }

    private void OnChatMessageReceived(ChatMessage message)
    {
        System.Windows.Application.Current.Dispatcher.Invoke(() =>
        {
            ChatMessages.Add(message);
            if (ChatMessages.Count > 500)
                ChatMessages.RemoveAt(0);
        });
    }

    private void BeginGoLive()
    {
        var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
        {
            StreamTitle = DefaultStreamTitle,
            StreamDescription = DefaultStreamDescription,
        };
        var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
        if (window.ShowDialog() == true)
        {
            StreamTitle = dialog.StreamTitle;
            StreamDescription = dialog.StreamDescription;
            StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
            WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
                ? "ytLlive"
                : $"{dialog.StreamTitle} — ytLlive";
            StreamStatus = StreamStatus.Streaming;
            ResetHealth(StreamStatus.Streaming);
            _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
            _ = CreateBroadcastAsync();
            _ = _framePump.StartAsync(); // never throws; failures log + surface via Failed
        }
    }

    /// <summary>Creates the YouTube broadcast (always private — enforced in the
    /// stream service) and remembers its ID for TASK 5's bind/transition. A
    /// failure surfaces as an error health state, never a crash.</summary>
    private async Task CreateBroadcastAsync()
    {
        try
        {
            _currentBroadcastId = await _youtubeStream.CreateBroadcast(
                StreamTitle, StreamDescription, DateTime.UtcNow);
            if (_currentBroadcastId == null)
            {
                AppLog.Write("Broadcast creation failed; check the OAuth session");
                StreamStatus = StreamStatus.Error;
            }
            else
            {
                AppLog.Write($"Broadcast created: {_currentBroadcastId}");
            }
        }
        catch (Exception ex)
        {
            AppLog.Write($"Broadcast creation failed: {ex.Message}");
            StreamStatus = StreamStatus.Error;
        }
    }

    private void StopStream()
    {
        StreamStatus = StreamStatus.Offline;
        WindowTitle = "ytLlive";
        ResetHealth(StreamStatus.Offline);
        // Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
        // then stop the pump (stdin EOF) — both inputs end and the encoder
        // finalizes the FLV. Audio capture itself is always-on (preview
        // monitoring); only the live pipe, the pump, and the session stop here.
        _audioMixer.StopLive();
        _ = _framePump.StopAsync();
        _currentBroadcastId = null;
        // Graceful end completes the session = signs out (the DPAPI token is
        // cleared so the next Start Stream requires a fresh sign-in). A crash
        // never runs this, so the token survives and the creator stays signed in.
        _youtubeAuth.ClearSession();
        TokenStore.Clear();
        IsConnected = false;
        SyncConnectedAccount();
        AppLog.Write("Stream ended; session signed out");
    }

    private void OnMicLevelChanged(float level)
    {
        // NAudio raises on its capture thread; marshal to the UI thread so the
        // meter binding updates safely.
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher != null && !dispatcher.CheckAccess())
            dispatcher.BeginInvoke(() => AudioLevel = level);
        else
            AudioLevel = level;
    }

    private void OnLoopbackLevelChanged(float level)
    {
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher != null && !dispatcher.CheckAccess())
            dispatcher.BeginInvoke(() => GameAudioLevel = level);
        else
            GameAudioLevel = level;
    }

    private void OnMicConnected()
    {
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher != null && !dispatcher.CheckAccess())
            dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected);
        else
            MicStatus = MicStatus.Connected;
    }

    private void OnMicFailed(Exception ex)
    {
        // The mixer logs the failure detail; here we only flip the dot to yellow.
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher != null && !dispatcher.CheckAccess())
            dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem);
        else
            MicStatus = MicStatus.Problem;
    }

    /// <summary>Starts capture once at startup: with no mic device present the
    /// dot stays red and capture never starts; otherwise the mixer starts and
    /// raises MicConnected (green) or MicFailed (yellow).</summary>
    private async Task StartMicCaptureAsync()
    {
        try
        {
            var mics = await _microphoneEnumerator.GetMicrophonesAsync();
            if (mics.Count == 0)
            {
                MicStatus = MicStatus.NotConnected;
                AppLog.Write("Mic: no capture devices found — mic capture not started");
                return;
            }
            _audioMixer.Start();
        }
        catch (Exception ex)
        {
            AppLog.Write($"Mic: device check failed: {ex.Message}");
        }
    }

    private void OnGameAudioActiveChanged(bool active)
    {
        _gameAudioBarActive = active;
        OnPropertyChanged(nameof(IsGameAudioBarVisible));
    }

    private void OnGameAudioPollTick(object? sender, EventArgs e)
    {
        try
        {
            _gameAudioDetector.Poll();
        }
        catch (Exception ex)
        {
            AppLog.Write($"Game audio detection failed: {ex.Message}");
        }
    }

    // Scene-element → latest frame, for the live compositor. The map mirrors the
    // preview: webcam by DeviceId, live captures (the backdrop) by CaptureKey,
    // images/background by AssetId. A null frame leaves the element transparent.
    private VideoFrame? ResolveOutputFrame(SceneElement element)
    {
        return element switch
        {
            WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId),
            Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
            Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
            _ => null,
        };
    }

    // The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
    // OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
    private CompositorOptions BuildCompositorOptions()
    {
        var quality = SelectedQuality;
        return new CompositorOptions
        {
            SourceRectX = (int)Math.Round(OutputRectX),
            SourceRectY = (int)Math.Round(OutputRectY),
            SourceRectWidth = (int)Math.Round(OutputRectWidth),
            SourceRectHeight = (int)Math.Round(OutputRectHeight),
            OutputWidth = quality.Width,
            OutputHeight = quality.Height,
        };
    }

    // Full encoder options for the current tier, or null when no RTMP URL is
    // available — the pump then skips the encoder entirely (TASK 5 fills the seam).
    private EncoderOptions? BuildEncoderOptions()
    {
        var url = _rtmpUrlProvider();
        if (string.IsNullOrWhiteSpace(url)) return null;
        var quality = SelectedQuality;
        return new EncoderOptions
        {
            RtmpUrl = url,
            Width = quality.Width,
            Height = quality.Height,
            Fps = quality.Fps,
            BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
        };
    }

    private void OnFramePumpFailed(object? sender, string message)
    {
        AppLog.Write($"Frame pump failed: {message}");
        if (IsLive) StreamStatus = StreamStatus.Error;
    }

    // TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
    // duration) lands in the bottom bar. The stderr loop raises on a background
    // thread — marshal to the UI thread like the audio level handlers.
    private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
    {
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher != null && !dispatcher.CheckAccess())
            dispatcher.BeginInvoke(() => ApplyHealth(health));
        else
            ApplyHealth(health);
    }

    private void ApplyHealth(StreamHealth health)
    {
        CurrentHealth.Status = health.Status;
        CurrentHealth.CurrentBitrate = health.CurrentBitrate;
        CurrentHealth.FPS = health.FPS;
        CurrentHealth.DroppedFrames = health.DroppedFrames;
        CurrentHealth.StreamDuration = health.StreamDuration;
        CurrentHealth.LastError = health.LastError;
        CurrentHealth.HealthMessage = health.HealthMessage;
        OnPropertyChanged(nameof(CurrentHealth));
    }

    // Keeps the bottom-bar stats honest across sessions: dropped frames and the
    // elapsed duration must not linger from a previous go-live (bitrate/FPS stay
    // on the tier's targets — ApplyStreamQuality sets them on pick).
    private void ResetHealth(StreamStatus status)
    {
        CurrentHealth.Status = status;
        CurrentHealth.DroppedFrames = 0;
        CurrentHealth.StreamDuration = TimeSpan.Zero;
        CurrentHealth.LastError = null;
        OnPropertyChanged(nameof(CurrentHealth));
    }

    private void UpdateLiveVisuals()
    {
        var live = IsLive;
        TopBarBackground = live ? "#e94560" : "#16213e";
        PreviewGlowBrush = live ? "#e94560" : "Transparent";
        PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);

        if (live)
        {
            _liveElapsed = TimeSpan.Zero;
            LiveElapsedText = "00:00:00";
            _recDotPulse = 1.0;
            OnPropertyChanged(nameof(RecDotOpacity));
            _liveTimer.Start();
            if (BrandFlashEnabled) StartBrandFlashTimer();
        }
        else
        {
            _liveTimer.Stop();
            _brandFlashTimer.Stop();
            _brandFlashOffTimer.Stop();
            BrandFlashActive = false;
            LiveElapsedText = "00:00:00";
            _recDotPulse = 1.0;
            OnPropertyChanged(nameof(RecDotOpacity));
        }
    }

    // First flash shortly after go-live, then every 300s (the cadence resets on
    // the first tick so the interval is 5s only for that one shot).
    private void StartBrandFlashTimer()
    {
        _brandFlashTimer.Stop();
        _brandFlashTimer.Interval = TimeSpan.FromSeconds(5);
        _brandFlashTimer.Start();
    }

    private void OnBrandFlashTimerTick(object? sender, EventArgs e)
    {
        _brandFlashTimer.Stop();
        _brandFlashTimer.Interval = TimeSpan.FromSeconds(300);
        _brandFlashTimer.Start();
        ShowBrandFlash();
    }

    private void ShowBrandFlash()
    {
        if (!IsLive || !BrandFlashEnabled) return;
        BrandFlashActive = true;
        _brandFlashOffTimer.Stop();
        _brandFlashOffTimer.Start();
    }

    private void OnLiveTimerTick(object? sender, EventArgs e)
    {
        _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
        LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
        _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
        OnPropertyChanged(nameof(RecDotOpacity));
    }

    // ─── Social bar ───

    /// <summary>
    /// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every
    /// fediverse entry that doesn't have one, so the bar shows the instance's real
    /// logo instead of the generic fediverse glyph. Best-effort: a failed resolution
    /// leaves the glyph untouched. Returns handle → software for what was resolved —
    /// the caller applies + persists (the app hops to the dispatcher; a test applies
    /// directly).
    /// </summary>
    public static async Task<Dictionary<string, string>> HealFediverseSoftwareAsync(
        SocialsConfig socials,
        ISocialValidator validator,
        Action<string>? log = null)
    {
        var resolved = new Dictionary<string, string>();
        if (socials == null || validator == null) return resolved;
        foreach (var entry in socials.Entries)
        {
            if (entry.Service != SocialService.Fediverse
                || !string.IsNullOrWhiteSpace(entry.FediverseSoftware))
                continue;
            if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain))
                continue;
            var software = await validator.ResolveFediverseSoftwareAsync(
                domain, System.Threading.CancellationToken.None);
            if (string.IsNullOrWhiteSpace(software)) continue;
            resolved[entry.Handle] = software;
            log?.Invoke($"Socials heal: {entry.Handle} → {software}");
        }
        return resolved;
    }

    /// <summary>Runs the heal off the UI thread and applies + saves any result.</summary>
    private void HealFediverseSoftwareInBackground()
    {
        var socials = _socials;
        if (socials == null) return;
        _ = Task.Run(async () =>
        {
            try
            {
                var resolved = await HealFediverseSoftwareAsync(
                    socials, _socialValidator, m => AppLog.Write(m));
                if (resolved.Count == 0) return;
                await Application.Current.Dispatcher.InvokeAsync(() =>
                {
                    var current = _socials;
                    if (current == null) return;
                    var applied = false;
                    foreach (var entry in current.Entries)
                    {
                        if (!resolved.TryGetValue(entry.Handle, out var software)) continue;
                        if (entry.FediverseSoftware == software) continue;
                        entry.FediverseSoftware = software;
                        applied = true;
                    }
                    if (applied) NotifySocialsChanged(); // re-renders the bar + saves
                });
            }
            catch (Exception ex)
            {
                AppLog.Write($"Socials heal failed: {ex.Message}");
            }
        });
    }

    /// <summary>
    /// Re-rasterizes the social bar strip the output compositor overlays. UI thread
    /// only (WPF rendering); the resulting frame is immutable, so the frame pump may
    /// read it from its own thread. Null when the bar is off or empty.
    /// </summary>
    private void RenderSocialBarFrame()
    {
        _socialBarFrame = _socials != null && _socials.BarEnabled
            ? SocialBarRenderer.Render(_socials.Entries)
            : null;
    }

    /// <summary>
    /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate,
    /// validation). On save the working copy replaces <see cref="_socials"/>.
    /// </summary>
    private void OpenSocialDialog()
    {
        var dialog = new SocialsDialogViewModel(
            _socialValidator,
            SignInAsync,
            SignOutYouTubeAsync,
            _youtubeAuth.CurrentChannel,
            IsPremium,
            _socials);
        var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow };
        if (window.ShowDialog() == true)
        {
            _socials = dialog.BuildSocialsConfig();
            NotifySocialsChanged();
        }
    }

    private void NotifySocialsChanged()
    {
        RenderSocialBarFrame();
        OnPropertyChanged(nameof(HasSocials));
        OnPropertyChanged(nameof(SocialBarVisible));
        OnPropertyChanged(nameof(SocialBarDotBrush));
        ScheduleSave();
    }

    /// <summary>Sets the bar's edge; called by the preview click-toggle.</summary>
    public void SetSocialBarPosition(SocialBarPosition position)
    {
        if (_socials == null || _socials.BarPosition == position) return;
        _socials.BarPosition = position;
        OnPropertyChanged(nameof(SocialBarTop));
        ScheduleSave();
    }

    /// <summary>Preview click flips the bar top ⇄ bottom (KISS — no drag math).</summary>
    public void ToggleSocialBarPosition()
        => SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top
            ? SocialBarPosition.Bottom
            : SocialBarPosition.Top);

    /// <summary>Signs out of YouTube (the delete-the-YouTube-slot action in the dialog).</summary>
    private async Task SignOutYouTubeAsync()
    {
        _youtubeAuth.ClearSession();
        TokenStore.Clear();
        IsConnected = false;
        SyncConnectedAccount();
        NotifySocialsChanged();
        AppLog.Write("Socials: signed out of YouTube");
        await Task.CompletedTask;
    }
}
