using System.IO;
using System.Windows;
using DragonISO.App.Services;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;

namespace DragonISO.App.ViewModels;

/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
/// </summary>
public sealed class GlobalSettingsViewModel : ObservableObject
{
    private readonly IIsoController _controller;
    private readonly ToastViewModel? _toast;
    private TargetFramerate _framerate;
    private TargetResolution _resolution;
    private AspectMode _aspect;
    private AudioMode _audio;
    private string _discoveryGroups;
    private string _outputGroups;
    private bool _hideLocalSelf = true;
    private bool _autoDisableOnDeparture = false;
    private bool _autoApplyLastPreset;
    // Recording-related fields removed alongside the rest of the recording surface.
    private bool _controlSurfaceEnabled;
    private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
    private bool _oscBridgeEnabled;
    private int _oscBridgePort = OscBridge.DefaultPort;
    private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
    private string _outputNameTemplate = DragonISO.App.Services.OutputNameTemplate.Get();
    private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
    private bool _minimizeToTray;
    private bool _controlSurfaceLanReachable;
    private bool _launchTeamsOnStartup;
    private bool _autoHideTeamsWindows;
    // _autoRecordOnCall removed â€” recording surface axed.
    private bool _embedTeamsWindow;

    public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
    {
        _controller = controller;
        _toast = toast;
        var current = controller.GlobalSettings;
        _framerate = current.Framerate;
        _resolution = current.Resolution;
        _aspect = current.Aspect;
        _audio = current.Audio;

        var groups = controller.GroupSettings;
        _discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
        _outputGroups = groups.OutputGroups ?? string.Empty;

        // Restore persisted UI toggles so the operator's preference survives
        // process restarts. UIPreferences keeps a tiny JSON file under
        // %LOCALAPPDATA%\Dragon-ISO\ui-prefs.json â€” defaults match the original
        // in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
        var uiPrefs = UIPreferences.Load();
        _hideLocalSelf = uiPrefs.HideLocalSelf;
        _autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
        _participantSort = uiPrefs.ParticipantSort;
        _minimizeToTray = uiPrefs.MinimizeToTray;
        _controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
        _launchTeamsOnStartup = uiPrefs.LaunchTeamsOnStartup;
        _autoHideTeamsWindows = uiPrefs.AutoHideTeamsWindows;
        // AutoRecordOnCall removed â€” recording surface axed.
        _embedTeamsWindow = uiPrefs.EmbedTeamsWindow;
        _controlSurfaceEnabled = uiPrefs.ControlSurfaceEnabled;

        // Bring the auto-apply flag in from the presets store so the checkbox
        // reflects the user's prior choice when the settings panel opens.
        try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
        catch { /* best-effort â€” disk read failures shouldn't block UI startup */ }

        // Recording-directory init removed alongside the rest of the recording surface.

        ApplyCommand = new AsyncRelayCommand(ApplyAsync);
        ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
        ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
        CopyControlSurfaceUrlCommand = new RelayCommand(() =>
        {
            try
            {
                System.Windows.Clipboard.SetText(ControlSurfaceUrl);
                _toast?.Show($"Copied: {ControlSurfaceUrl}");
            }
            catch
            {
                // Clipboard occasionally errors when something else has it locked.
            }
        });
        OpenControlSurfaceCommand = new RelayCommand(() =>
        {
            // Hands the URL off to the OS shell so the user's default browser
            // opens it. Operators previewing how the control panel looks on
            // their phone / tablet / second monitor would otherwise have to
            // copy-paste the URL â€” this is a one-click preview.
            try
            {
                System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
                {
                    FileName = ControlSurfaceUrl,
                    UseShellExecute = true,
                });
            }
            catch (Exception ex)
            {
                _toast?.Warn($"Couldn't open: {ex.Message}");
            }
        });
    }

    /// <summary>
    /// Open the embedded HTML control panel (the <c>/ui</c> endpoint) in the
    /// default browser. Enabled regardless of whether the control surface is
    /// running â€” if it isn't, the browser will show a connection error, which
    /// is informative; operators learn the surface needs to be enabled first.
    /// </summary>
    public RelayCommand OpenControlSurfaceCommand { get; }

    private void ResetOutputDefaults()
    {
        var confirm = MessageBox.Show(
            "Reset framerate, resolution, aspect and audio to Dragon-ISO defaults?\n\n" +
            "This won't touch your NDI group configuration or display toggles.",
            "Dragon-ISO â€” Reset output defaults",
            MessageBoxButton.YesNo,
            MessageBoxImage.Question,
            MessageBoxResult.No);
        if (confirm != MessageBoxResult.Yes) return;

        var defaults = FrameProcessingSettings.Default;
        Framerate = defaults.Framerate;
        Resolution = defaults.Resolution;
        Aspect = defaults.Aspect;
        Audio = defaults.Audio;
        _toast?.Show("Output settings reset to defaults â€” click Apply Changes to commit");
    }

    public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
    public IEnumerable<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
    public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
    public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();

    public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
    public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
    public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
    public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }

    /// <summary>NDI discovery group(s) â€” comma-separated. Empty = default (Public).</summary>
    public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }

    /// <summary>NDI output group(s) â€” comma-separated. Empty = default (Public).</summary>
    public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }

    /// <summary>
    /// Hide the user's own self-preview ("(Local)") from the participants list.
    /// On by default â€” operators rarely want to ISO-route their own preview.
    /// Read by <see cref="MainViewModel"/> when filtering the list it presents.
    /// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
    /// </summary>
    public bool HideLocalSelf
    {
        get => _hideLocalSelf;
        set
        {
            if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
        }
    }

    /// <summary>
    /// When a participant leaves the meeting (their NDI source disappears),
    /// automatically tear down their ISO pipeline. Off by default so transient
    /// drops don't lose the operator's routing â€” but useful for clean
    /// show-end behavior. Read by MainViewModel when reconciling departures.
    /// Persisted to <c>ui-prefs.json</c>.
    /// </summary>
    public bool AutoDisableOnDeparture
    {
        get => _autoDisableOnDeparture;
        set
        {
            if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
        }
    }

    /// <summary>
    /// Available sort modes for the dropdown in DISPLAY settings.
    /// </summary>
    public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();

    /// <summary>
    /// How the participants DataGrid is sorted. Persisted across launches via
    /// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
    /// on Application.Current to actually apply the sort to the live view â€”
    /// the settings VM doesn't directly know about the main VM but App holds
    /// both and exposes the main window via its DataContext.
    /// </summary>
    public UIPreferences.SortMode ParticipantSort
    {
        get => _participantSort;
        set
        {
            if (!SetField(ref _participantSort, value)) return;
            PersistUiPrefs();
            // Apply to the live view immediately. App.MainWindow.DataContext
            // is the MainViewModel; cast and call.
            var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
            main?.SetSortMode(value);
        }
    }

    /// <summary>
    /// Minimize-to-tray behavior. When on, minimizing the main window hides
    /// it from the taskbar and shows a tray icon (double-click to restore).
    /// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
    /// Useful for long unattended shows where the operator wants Dragon-ISO
    /// running but invisible.
    /// </summary>
    public bool MinimizeToTray
    {
        get => _minimizeToTray;
        set
        {
            if (!SetField(ref _minimizeToTray, value)) return;
            PersistUiPrefs();
            // Reach into the App-owned tray host. App constructs it after the
            // main window exists, so the cast is safe at any time the settings
            // panel is interactable.
            var tray = (Application.Current as App)?.TrayIcon;
            if (tray is not null) tray.Enabled = value;
            _toast?.Show(value
                ? "Minimize-to-tray enabled â€” minimizing now hides the window"
                : "Minimize-to-tray disabled");
        }
    }

    /// <summary>
    /// Snapshot the current persistable UI state to disk. Called from any
    /// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort â€” disk
    /// failures don't surface to the operator (the in-memory state still
    /// reflects their click for this session).
    /// </summary>
    private void PersistUiPrefs()
    {
        // Theme isn't owned by this VM â€” read whatever ThemeManager has
        // persisted (or default) so we don't clobber it on save.
        var existing = UIPreferences.Load();
        UIPreferences.Save(new UIPreferences.Prefs(
            HideLocalSelf: _hideLocalSelf,
            AutoDisableOnDeparture: _autoDisableOnDeparture,
            ParticipantSort: _participantSort,
            MinimizeToTray: _minimizeToTray,
            ControlSurfaceLanReachable: _controlSurfaceLanReachable,
            LaunchTeamsOnStartup: _launchTeamsOnStartup,
            AutoHideTeamsWindows: _autoHideTeamsWindows,
            EmbedTeamsWindow: _embedTeamsWindow,
            Theme: existing.Theme,
            ControlSurfaceEnabled: _controlSurfaceEnabled));
    }

    /// <summary>
    /// Auto-launch the Microsoft Teams desktop client when Dragon-ISO starts.
    /// Paired with <see cref="AutoHideTeamsWindows"/> gives the operator a
    /// "Dragon-ISO is the only window I see" experience â€” Teams runs in the
    /// background, all interaction happens through the participants DataGrid
    /// + IN-CALL bar.
    /// </summary>
    public bool LaunchTeamsOnStartup
    {
        get => _launchTeamsOnStartup;
        set
        {
            if (SetField(ref _launchTeamsOnStartup, value)) PersistUiPrefs();
        }
    }

    /// <summary>
    /// Auto-hide Teams' top-level windows as soon as they materialize after
    /// a launch (whether triggered via <see cref="LaunchTeamsOnStartup"/>,
    /// the rail button, or the eye-toggle). Runs a brief background poll
    /// that calls <c>TeamsLauncher.HideWindows</c> every ~250ms for up to
    /// 15 seconds, catching splash + main + follow-up panels.
    /// </summary>
    public bool AutoHideTeamsWindows
    {
        get => _autoHideTeamsWindows;
        set
        {
            if (SetField(ref _autoHideTeamsWindows, value)) PersistUiPrefs();
        }
    }

    // AutoRecordOnCall / RecordIsosToDisk / RecordingDirectory properties
    // removed alongside the rest of the recording surface.

    /// <summary>
    /// EXPERIMENTAL: SetParent-reparents Teams' main window into a Dragon-ISO-
    /// owned host so Teams visually appears inside our window. WebView2 in
    /// modern Teams may render weirdly after reparent â€” if so, untick and
    /// fall back to the auto-hide flow. Polling logic in MainWindow.xaml.cs
    /// applies / restores the embed; this property is just the persisted
    /// toggle.
    /// </summary>
    public bool EmbedTeamsWindow
    {
        get => _embedTeamsWindow;
        set
        {
            if (SetField(ref _embedTeamsWindow, value)) PersistUiPrefs();
        }
    }

    /// <summary>
    /// REST control surface â€” Stream Deck / Companion / thin-client controllers.
    /// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
    /// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
    /// </summary>
    public bool ControlSurfaceEnabled
    {
        get => _controlSurfaceEnabled;
        set
        {
            if (!SetField(ref _controlSurfaceEnabled, value)) return;
            PersistUiPrefs();
            var srv = (Application.Current as App)?.ControlSurface;
            if (srv is null) return;
            if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
            else srv.Stop();
            _toast?.Show(value
                ? $"Control surface listening on {ControlSurfaceUrl}"
                : "Control surface stopped");
        }
    }

    /// <summary>
    /// Port the control surface binds to. Editable while the surface is off; while on,
    /// changing the port stops + restarts the listener on the new port.
    /// </summary>
    public int ControlSurfacePort
    {
        get => _controlSurfacePort;
        set
        {
            if (!SetField(ref _controlSurfacePort, value)) return;
            OnPropertyChanged(nameof(ControlSurfaceUrl));
            if (!_controlSurfaceEnabled) return;
            var srv = (Application.Current as App)?.ControlSurface;
            srv?.Start(value, _controlSurfaceLanReachable);
            _toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
        }
    }

    /// <summary>
    /// LAN-reachable mode. When false (default), control surface binds to
    /// 127.0.0.1 â€” only this machine. When true, binds to all interfaces so
    /// a thin-client controller on a phone or another laptop can drive
    /// DragonISO. The OSC bridge follows suit if it's running.
    ///
    /// Important: HttpListener requires either Administrator privilege OR a
    /// one-time URL ACL reservation for non-loopback prefixes:
    /// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
    /// (run from an elevated PowerShell). Without that the listener throws
    /// AccessDeniedException on Start; the failure surfaces as a logger
    /// warning with the exact netsh command.
    /// </summary>
    public bool ControlSurfaceLanReachable
    {
        get => _controlSurfaceLanReachable;
        set
        {
            if (!SetField(ref _controlSurfaceLanReachable, value)) return;
            PersistUiPrefs();
            OnPropertyChanged(nameof(ControlSurfaceUrl));
            if (!_controlSurfaceEnabled) return;
            var srv = (Application.Current as App)?.ControlSurface;
            srv?.Start(_controlSurfacePort, value);
            var osc = (Application.Current as App)?.OscBridge;
            if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
            _toast?.Show(value
                ? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
                : "Control surface now loopback-only");
        }
    }

    /// <summary>
    /// Friendly URL of the running surface, for the settings panel + status
    /// bar tooltip. Resolves to the first non-loopback IPv4 address when
    /// LAN-reachable; loopback otherwise. Computed on demand because the
    /// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
    /// </summary>
    public string ControlSurfaceUrl
    {
        get
        {
            var host = _controlSurfaceLanReachable
                ? GetLanIPv4() ?? "127.0.0.1"
                : "127.0.0.1";
            return $"http://{host}:{_controlSurfacePort}/ui";
        }
    }

    /// <summary>
    /// Best-effort routable IPv4 address suitable for showing the operator a
    /// "paste me into the thin client" URL. Skips:
    ///   â€¢ loopback interfaces (127.x)
    ///   â€¢ tunnel/virtual interfaces (NetworkInterfaceType.Tunnel â€” e.g. WSL,
    ///     Hyper-V, Tailscale, OpenVPN-style virtuals)
    ///   â€¢ APIPA/link-local addresses (169.254.x â€” assigned when DHCP fails;
    ///     a host with one of these AND a real DHCP lease should pick the lease)
    /// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
    /// first non-link-local non-loopback IPv4. Returns null only if no
    /// usable address exists at all.
    /// </summary>
    private static string? GetLanIPv4()
    {
        try
        {
            string? linkLocalFallback = null;
            string? otherFallback = null;
            foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
            {
                if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
                if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
                if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
                var isPhysical =
                    ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet ||
                    ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.GigabitEthernet ||
                    ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211;

                foreach (var ua in ni.GetIPProperties().UnicastAddresses)
                {
                    if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
                    if (System.Net.IPAddress.IsLoopback(ua.Address)) continue;
                    var addr = ua.Address.ToString();
                    var isLinkLocal = addr.StartsWith("169.254.", StringComparison.Ordinal);

                    if (isPhysical && !isLinkLocal) return addr;       // best
                    if (!isLinkLocal) otherFallback ??= addr;          // routable but virtual NIC
                    if (isLinkLocal) linkLocalFallback ??= addr;       // worst
                }
            }
            return otherFallback ?? linkLocalFallback;
        }
        catch { /* best-effort */ }
        return null;
    }

    /// <summary>
    /// OSC bridge over UDP â€” same command surface as the REST endpoints,
    /// reachable from Companion / TouchOSC / lighting consoles. Off by default;
    /// bound to 127.0.0.1 only.
    /// </summary>
    public bool OscBridgeEnabled
    {
        get => _oscBridgeEnabled;
        set
        {
            if (!SetField(ref _oscBridgeEnabled, value)) return;
            var bridge = (Application.Current as App)?.OscBridge;
            if (bridge is null) return;
            if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
            else bridge.Stop();
            var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
            _toast?.Show(value
                ? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
                : "OSC bridge stopped");
        }
    }

    /// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
    public int OscBridgePort
    {
        get => _oscBridgePort;
        set
        {
            if (!SetField(ref _oscBridgePort, value)) return;
            if (!_oscBridgeEnabled) return;
            var bridge = (Application.Current as App)?.OscBridge;
            bridge?.Start(value, _controlSurfaceLanReachable);
            var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
            _toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
        }
    }

    /// <summary>
    /// Output-name template applied when the operator enables an ISO without
    /// a per-participant CustomName. Default <c>"{name}"</c> renders the
    /// speaker's display name directly (changed from the legacy
    /// <c>"Dragon-ISO_{guid}"</c> in 0.9.0-rc19, since downstream switchers
    /// almost always want human-readable identifiers). Switch back to a
    /// guid-based template if you need stable IDs that survive participant
    /// name changes. See <see cref="OutputNameTemplate"/> for the supported
    /// tokens.
    /// </summary>
    public string OutputNameTemplate
    {
        get => _outputNameTemplate;
        set
        {
            if (SetField(ref _outputNameTemplate, value))
            {
                DragonISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
            }
        }
    }

    /// <summary>
    /// Background update check on launch. Throttled to once per 24h via a
    /// timestamp file. When a newer release is found, surfaces a non-modal
    /// banner with a "Get update" button. Off-by-default would be friendlier
    /// for paranoid setups; on-by-default is friendlier for adoption.
    /// </summary>
    public bool UpdateCheckOnLaunch
    {
        get => _updateCheckOnLaunch;
        set
        {
            if (SetField(ref _updateCheckOnLaunch, value))
            {
                UpdateChecker.LaunchCheckEnabled = value;
                _toast?.Show(value
                    ? "Update checks enabled â€” runs once per 24h on launch"
                    : "Update checks disabled");
            }
        }
    }

    /// <summary>
    /// On launch, automatically re-apply the most recently applied operator preset.
    /// Closes the loop on the recurring-show workflow: the operator clicks Apply
    /// once, and from that point on Dragon-ISO restores the same routing on every
    /// subsequent launch as soon as the matching participants come online.
    /// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
    /// <see cref="OperatorPresetStore"/>.
    /// </summary>
    public bool AutoApplyLastPreset
    {
        get => _autoApplyLastPreset;
        set
        {
            if (SetField(ref _autoApplyLastPreset, value))
            {
                try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
                catch { /* best-effort */ }
            }
        }
    }

    public AsyncRelayCommand ApplyCommand { get; }

    /// <summary>
    /// Copy the current control-surface URL to the clipboard. Operators on a
    /// thin-client setup tap this, then paste into a phone browser. Bound to
    /// a small button next to the LAN-reachable toggle.
    /// </summary>
    public RelayCommand CopyControlSurfaceUrlCommand { get; }

    /// <summary>
    /// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
    /// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky â€”
    /// the operator's transcoder topology is a per-machine setting that survives
    /// preferences resets) and doesn't touch Display toggles. Confirms first.
    /// </summary>
    public RelayCommand ResetOutputDefaultsCommand { get; }

    /// <summary>
    /// One-click "set up the transcoder topology" â€” writes ndi-config.v1.json so all
    /// local senders broadcast on a private group ("Dragon-ISO-input") while local
    /// receivers can see both that and "public", then sets the engine's discovery and
    /// output groups to align (engine receives from the private group, emits on Public).
    /// User has to restart Teams for the new ndi-config.v1.json to take effect there.
    /// </summary>
    public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }

    private async Task ApplyAsync()
    {
        var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
        await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);

        var groups = new NdiGroupSettings(
            DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
            OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
        await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);

        _toast?.Show("Settings saved");
    }

    private async Task ApplyTranscoderTopologyAsync()
    {
        // 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
        //    private group instead of polluting Public.
        var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
        if (!result.Success)
        {
            MessageBox.Show(
                $"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
                "Dragon-ISO â€” Apply transcoder topology",
                MessageBoxButton.OK,
                MessageBoxImage.Warning);
            return;
        }

        // 2. Update the engine: receive only from the private group, emit on Public.
        var ourGroups = new NdiGroupSettings(
            DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
            OutputGroups: "public");
        await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);

        // 3. Reflect the new values in the bound text boxes.
        DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
        OutputGroups = "public";

        var backupNote = result.BackupPath is null
            ? "No prior NDI config existed; a fresh one was created."
            : $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";

        MessageBox.Show(
            "Transcoder topology applied. âœ“\n\n" +
            "â€¢ Local senders (Teams, etc.) will broadcast on group 'Dragon-ISO-input'.\n" +
            "â€¢ Local receivers will see both 'public' and 'Dragon-ISO-input'.\n" +
            "â€¢ Dragon-ISO will discover from 'Dragon-ISO-input' and re-emit on 'public'.\n\n" +
            "RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
            backupNote,
            "Dragon-ISO â€” Apply transcoder topology",
            MessageBoxButton.OK,
            MessageBoxImage.Information);

        _toast?.Show("Transcoder topology applied â€” restart Teams to take effect");
    }
}
