using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Input;
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;

namespace ytLive.ViewModels;

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

    private Scene? _activeScene;
    private SceneElement? _selectedElement;
    private ImageSource? _activeBackgroundImage;
    private bool _isConnected;
    private StreamStatus _streamStatus = StreamStatus.Offline;
    private StreamHealth _currentHealth = new();
    private string _streamTitle = string.Empty;
    private string _streamDescription = string.Empty;
    private string _streamVisibility = "Public";
    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 _livePulseOpacity = 1.0;
    private TimeSpan _liveElapsed;

    private bool _isSettingsOpen;
    private bool _isBugOpen;
    private bool _isFeatureOpen;
    private bool _isAboutOpen;
    private string _overlayTitle = string.Empty;
    private string _defaultStreamTitle = string.Empty;
    private string _defaultStreamDescription = string.Empty;
    private string _defaultStreamVisibility = "Public";
    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;

    // 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";
    private const string ChannelUrl = "https://youtube.com/@llamachileshop";
    public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";

    public ObservableCollection<Scene> Scenes { get; } = new();
    public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
    public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };

    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(CanAddWebcamToActiveScene));
                OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
                UpdateActiveBackground();
            }
        }
    }

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

    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;

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

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

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

    public bool IsOffline => StreamStatus == StreamStatus.Offline;
    public bool IsLive => StreamStatus == StreamStatus.Streaming;
    public bool LiveIndicatorVisible => IsLive;
    public bool ShowStartStream => IsOffline;
    public bool ShowChatInactiveMessage => !IsLive;
    public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
    public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
    public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;

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

    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 => SetProperty(ref _streamVisibility, value);
    }

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

    public double LivePulseOpacity
    {
        get => _livePulseOpacity;
        set => SetProperty(ref _livePulseOpacity, 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);
    }

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

    public string DefaultStreamVisibility
    {
        get => _defaultStreamVisibility;
        set => SetProperty(ref _defaultStreamVisibility, 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.
    public const double WebcamMaxWidth = 960;
    public const double WebcamMaxHeight = 540;
    public const double WebcamMinWidth = MasterFrameWidth * 0.1;
    public const double WebcamMinHeight = MasterFrameHeight * 0.1;

    internal static void ClampWebcamToBounds(WebcamSceneConfig config)
    {
        var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / 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 EditSceneCommand { get; }
    public ICommand RemoveSceneCommand { get; }
    public ICommand ToggleSceneVisibilityCommand { get; }
    public ICommand AddSourceCommand { get; }
    public ICommand AddImageCommand { get; }
    public ICommand RemoveSourceCommand { get; }
    public ICommand ChangeWebcamCommand { get; }
    public ICommand ShowWebcamCommand { 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 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;

        _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(_ => AddScene());
        EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
        RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
        ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
        AddSourceCommand = new RelayCommand(type => AddSource(type as string));
        AddImageCommand = new RelayCommand(_ => AddImage());
        RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
        ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
        ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
        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));
        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;

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

        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;
        }
        catch (Exception ex)
        {
            AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
        }
    }

    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)
            {
                AddScene("Starting");
                AddScene("Live");
                AddScene("BRB");
                AddScene("Chat", isChatScene: true);
                AddScene("Ending");
            }
            foreach (var scene in Scenes)
                foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
                {
                    ClampWebcamToBounds(config);
                    HealLegacySquareRect(config);
                }
            AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
        }
        finally
        {
            _isLoading = false;
        }
        ActiveScene = Scenes.FirstOrDefault();
        UpdateActiveBackground();
        ReacquireWebcam();
        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) return;
        foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
            _ = _cameraManager.AcquireAsync(newDevice);
    }

    // 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.
    private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
    {
        if (_webcam?.DeviceId != deviceId) return;
        foreach (var scene in Scenes)
            foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
                config.VideoImageSource = bitmap;
    }

    public void Shutdown()
    {
        _saveDebounce?.Stop();
        SaveLayoutNow();
        _cameraManager.Dispose();
        _layoutStore.Dispose();
    }

    public void SaveLayoutNow()
    {
        _saveDebounce?.Stop();
        try
        {
            _layoutStore.Save(Scenes, _webcam);
        }
        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);
        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)
        => 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();
    }

    private void AddScene(string? name = null, bool isChatScene = false)
    {
        var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene };
        Scenes.Add(scene);
        ActiveScene = scene;
    }

    private void BeginEditScene(Scene? scene)
    {
        if (scene == null) return;
        scene.IsEditing = true;
    }

    private void ToggleSceneVisibility(Scene? scene)
    {
        if (scene == null) return;
        scene.IsHidden = !scene.IsHidden;
        if (scene.IsHidden && ActiveScene == scene)
            ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden);
    }

    private void RemoveScene(Scene? scene)
    {
        if (scene == null) return;
        Scenes.Remove(scene);
        if (ActiveScene == scene)
            ActiveScene = Scenes.FirstOrDefault();
    }

    private void AddSource(string? type)
    {
        var scene = ActiveScene;
        if (scene == null) return;

        if (string.Equals(type, "webcam", StringComparison.OrdinalIgnoreCase))
        {
            _ = AddWebcamToActiveSceneAsync();
            return;
        }

        var sourceType = type?.ToLowerInvariant() switch
        {
            "screen" => SourceType.DisplayCapture,
            "window" => SourceType.WindowCapture,
            "background" => SourceType.Background,
            "text" => SourceType.TextOverlay,
            _ => 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;
        }

        var count = scene.Elements.OfType<Source>().Count(s => s.Type == sourceType);
        var name = count == 0 ? baseName : $"{baseName} {count + 1}";

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

    // Adds the webcam to the active scene. The camera is picked once app-wide
    // (first add); afterwards "Add Webcam" just places the existing webcam here
    // at the default spot — each scene's config is independent (webcam.{scene}.config).
    private async Task AddWebcamToActiveSceneAsync()
    {
        var scene = ActiveScene;
        if (scene == null || scene.WebcamConfig != null) return;

        if (_webcam == null)
        {
            var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
            {
                Owner = Application.Current.MainWindow
            };
            if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
            var device = dialog.PickedDevice;
            _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
            OnPropertyChanged(nameof(CanChangeWebcam));
        }

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

        var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
        if (!started)
        {
            MessageBox.Show(
                "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.
    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;

        var oldDevice = _webcam.DeviceId;
        var newDevice = dialog.PickedDevice.Id;
        if (oldDevice == newDevice) return;

        _webcam.DeviceId = newDevice;
        _webcam.Name = dialog.PickedDevice.DisplayName;
        ScheduleSave();

        if (!string.IsNullOrWhiteSpace(oldDevice))
            await _cameraManager.ReleaseAllAsync(oldDevice);

        foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
        {
            var started = await _cameraManager.AcquireAsync(newDevice);
            if (!started)
            {
                MessageBox.Show(
                    "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;
            }
        }
    }

    // "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 count = scene.Elements.OfType<Source>().Count(s => s.Type == SourceType.Image);
        var name = count == 0 ? "Image" : $"Image {count + 1}";

        var source = new Source { Name = name, 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 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;
    }

    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;
            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,
            Visibility = DefaultStreamVisibility,
        };
        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;
            WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
                ? "ytLlive"
                : $"{dialog.StreamTitle} — ytLlive";
            StreamStatus = StreamStatus.Streaming;
        }
    }

    private void StopStream()
    {
        StreamStatus = StreamStatus.Offline;
        WindowTitle = "ytLlive";
        // 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;
        AppLog.Write("Stream ended; session signed out");
    }

    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";
            LivePulseOpacity = 1.0;
            _liveTimer.Start();
            if (BrandFlashEnabled) StartBrandFlashTimer();
        }
        else
        {
            _liveTimer.Stop();
            _brandFlashTimer.Stop();
            _brandFlashOffTimer.Stop();
            BrandFlashActive = false;
            LiveElapsedText = "00:00:00";
            LivePulseOpacity = 1.0;
        }
    }

    // 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");
        LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0;
    }
}
