using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Threading;
using Microsoft.Extensions.Logging;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;

namespace TeamsISO.App.Services;

/// <summary>
/// Localhost-only HTTP control surface. Lets external controllers (Bitfocus
/// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows,
/// etc.) drive TeamsISO without needing to embed a UI binding.
///
/// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and
/// the typical operator workflow is "Stream Deck on the same machine as TeamsISO".
/// If a future user needs LAN access, add a token check + bind to a configurable
/// address; both are deliberately punted for v1.
///
/// Endpoints (all return application/json):
///
///   GET  /                          — server info + endpoint list
///   GET  /participants              — list of {id, displayName, isOnline, isEnabled}
///   POST /participants/{id}/iso     — body {"enabled":bool,"customName":string?}
///   POST /participants/iso          — body {"displayName":string,"enabled":bool} (look up by name)
///   POST /presets/{name}/apply      — apply a saved preset
///   POST /presets/refresh-discovery — rebuild NDI finder
///   POST /presets/stop-all          — disable every running ISO
///   POST /teams/mute                — toggle mute via UIA
///   POST /teams/camera              — toggle camera via UIA
///   POST /teams/leave               — leave the call via UIA
///   POST /teams/share               — open share tray via UIA
///   POST /teams/raise-hand          — toggle raise hand via UIA
///   POST /recording                 — body {"enabled":bool,"directory":string?}
///
/// All POST bodies are optional — endpoints that take parameters accept them
/// either via JSON body or via query string (?enabled=true&amp;customName=Host).
/// This is friendly to Companion's "URL with query string" mode.
/// </summary>
public sealed class ControlSurfaceServer : IAsyncDisposable
{
    public const int DefaultPort = 9755;

    private readonly IIsoController _controller;
    private readonly Func<MainViewModel?> _viewModel;
    private readonly ILogger<ControlSurfaceServer>? _logger;
    private HttpListener? _listener;
    private CancellationTokenSource? _cts;
    private Task? _acceptTask;
    private DispatcherTimer? _pushTimer;
    private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
    private string _lastPushedSnapshot = string.Empty;

    public bool IsRunning { get; private set; }
    public int Port { get; private set; } = DefaultPort;
    /// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
    public bool BoundToLan { get; private set; }

    /// <summary>
    /// JSON serializer options shared across all responses. Camel-case property
    /// naming matches Companion's request shape and what most JS clients expect.
    /// </summary>
    private static readonly JsonSerializerOptions JsonOpts = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    public ControlSurfaceServer(
        IIsoController controller,
        Func<MainViewModel?> viewModel,
        ILogger<ControlSurfaceServer>? logger = null)
    {
        _controller = controller;
        _viewModel = viewModel;
        _logger = logger;
    }

    /// <summary>
    /// Start listening on the given port. Idempotent: if already running on the
    /// same (port, bindToLan) combination, no-op; otherwise stop + restart.
    /// </summary>
    /// <param name="port">TCP port to listen on.</param>
    /// <param name="bindToLan">
    /// When true, binds to all interfaces (<c>http://+:port/</c>) so other
    /// machines on the LAN can reach the control surface — typical for
    /// "headless show machine + thin client controller" setups. When false
    /// (default), binds to <c>127.0.0.1</c> only.
    ///
    /// LAN binding requires either running TeamsISO as Administrator OR a
    /// one-time URL ACL reservation at the OS level:
    /// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
    /// If neither is in place the listener throws AccessDeniedException
    /// which we catch and surface as a logger warning.
    /// </summary>
    public void Start(int port, bool bindToLan = false)
    {
        if (IsRunning && Port == port && BoundToLan == bindToLan) return;
        Stop();

        Port = port;
        BoundToLan = bindToLan;
        _listener = new HttpListener();
        var prefix = bindToLan
            ? $"http://+:{port}/"
            : $"http://127.0.0.1:{port}/";
        _listener.Prefixes.Add(prefix);
        try
        {
            _listener.Start();
        }
        catch (HttpListenerException ex)
        {
            _logger?.LogWarning(ex,
                "Could not start control surface on {Prefix}. " +
                "If binding to LAN, run as Administrator once OR run: " +
                "netsh http add urlacl url=http://+:{Port}/ user=Everyone",
                prefix, port);
            _listener = null;
            return;
        }
        _cts = new CancellationTokenSource();
        _acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token));

        // Drive the WebSocket push loop on the UI dispatcher so we can read the
        // ObservableCollection-backed Participants list without thread races. 4Hz
        // is fast enough that operators see immediate feedback when they flip an
        // ISO on the Stream Deck without us spamming the wire when nothing's
        // changing — the snapshot serializer dedupes against the previous push.
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher is not null)
        {
            _pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
            {
                Interval = TimeSpan.FromMilliseconds(250),
            };
            _pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync();
            _pushTimer.Start();
        }

        IsRunning = true;
        _logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix);
    }

    public void Stop()
    {
        if (!IsRunning) return;
        try { _pushTimer?.Stop(); } catch { /* ignore */ }
        _pushTimer = null;
        // Close + drop every connected WebSocket; clients will reconnect when the
        // operator re-enables the surface.
        foreach (var (id, ws) in _clients.ToArray())
        {
            try { ws.Abort(); } catch { /* ignore */ }
            try { ws.Dispose(); } catch { /* ignore */ }
            _clients.TryRemove(id, out _);
        }
        try { _cts?.Cancel(); } catch { /* ignore */ }
        try { _listener?.Stop(); } catch { /* ignore */ }
        try { _listener?.Close(); } catch { /* ignore */ }
        try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
        _listener = null;
        _cts?.Dispose();
        _cts = null;
        _acceptTask = null;
        IsRunning = false;
    }

    public async ValueTask DisposeAsync()
    {
        Stop();
        await Task.CompletedTask;
    }

    private async Task AcceptLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening)
        {
            HttpListenerContext ctx;
            try { ctx = await _listener.GetContextAsync(); }
            catch (HttpListenerException) { break; } // listener stopped
            catch (ObjectDisposedException) { break; }
            catch (InvalidOperationException) { break; }

            // Each request gets its own task so a slow handler doesn't head-of-line block
            // others. Handlers are short (no I/O beyond the controller call) so this is
            // fine without explicit concurrency limits.
            _ = Task.Run(() => HandleRequestAsync(ctx));
        }
    }

    private async Task HandleRequestAsync(HttpListenerContext ctx)
    {
        var req = ctx.Request;
        var res = ctx.Response;
        // Tracks whether we should call res.Close() in the finally. WebSocket
        // upgrades transfer ownership of the connection to the WebSocket
        // instance — closing the response here would tear down the freshly-
        // upgraded socket immediately. So we skip the finally close on that
        // path.
        var closeResponseInFinally = true;
        try
        {
            res.Headers["Access-Control-Allow-Origin"] = "*";
            if (req.HttpMethod == "OPTIONS")
            {
                res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
                res.Headers["Access-Control-Allow-Headers"] = "Content-Type";
                res.StatusCode = 204;
                return;
            }

            var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? "";

            // WebSocket upgrade: live state push for controllers that don't want
            // to poll. Returns immediately after upgrading; HandleWebSocketAsync
            // owns the connection until the client disconnects.
            if (req.IsWebSocketRequest && path == "/ws")
            {
                var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null);
                closeResponseInFinally = false;
                _ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket));
                return;
            }

            var body = await ReadBodyAsync(req);

            // GET /ui — embedded HTML control panel. Served as text/html
            // rather than JSON so a browser renders it directly.
            if (req.HttpMethod == "GET" && path == "/ui")
            {
                res.ContentType = "text/html; charset=utf-8";
                var html = ControlPanelHtml.Get();
                var bytes = System.Text.Encoding.UTF8.GetBytes(html);
                res.ContentLength64 = bytes.Length;
                await res.OutputStream.WriteAsync(bytes);
                return;
            }

            object? response = (req.HttpMethod, path) switch
            {
                ("GET", "" or "/")              => GetServerInfo(),
                ("GET", "/participants")        => GetParticipants(),
                ("POST", "/presets/refresh-discovery") => RefreshDiscovery(),
                ("POST", "/presets/stop-all")   => await StopAllAsync(),
                ("POST", "/teams/mute")         => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"),
                ("POST", "/teams/camera")       => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"),
                ("POST", "/teams/leave")        => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"),
                ("POST", "/teams/share")        => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"),
                ("POST", "/teams/raise-hand")   => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"),
                ("POST", "/recording")          => SetRecording(body, req.QueryString),
                ("POST", "/recording/marker")   => DropMarker(body, req.QueryString),
                ("POST", "/recording/roll")     => await RollRecordingAsync(),
                ("POST", "/notes")              => AppendNote(body, req.QueryString),
                ("POST", "/participants/iso")   => await ToggleIsoByNameAsync(body, req.QueryString),
                _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
                                                => await ToggleIsoByIdAsync(path, body, req.QueryString),
                _ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal)
                                                && path.EndsWith("/apply", StringComparison.Ordinal)
                                                => await ApplyPresetAsync(path),
                _ => NotFound(),
            };

            if (response is null)
            {
                res.StatusCode = 404;
                await WriteJsonAsync(res, new { error = "not found" });
                return;
            }
            await WriteJsonAsync(res, response);
        }
        catch (Exception ex)
        {
            _logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath);
            try
            {
                res.StatusCode = 500;
                await WriteJsonAsync(res, new { error = ex.Message });
            }
            catch { /* defensive */ }
        }
        finally
        {
            if (closeResponseInFinally)
            {
                try { res.Close(); } catch { /* defensive */ }
            }
        }
    }

    // ─── handlers ───────────────────────────────────────────────────────

    private object GetServerInfo()
    {
        // Best-effort engine snapshot — wrapped in try/catch so a transient
        // controller error doesn't 500 the homepage poll.
        var settings = TryRead(() => _controller.GlobalSettings);
        var groups = TryRead(() => _controller.GroupSettings);
        return new
        {
            product = "TeamsISO",
            version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown",
            engine = new
            {
                framerateHz = settings?.FramerateHz,
                targetResolution = settings?.Resolution.ToString(),
                aspectMode = settings?.Aspect.ToString(),
                audioMode = settings?.Audio.ToString(),
                discoveryGroups = groups?.DiscoveryGroups,
                outputGroups = groups?.OutputGroups,
            },
            recording = new
            {
                enabled = _controller.RecordingEnabled,
                directory = _controller.RecordingDirectory,
            },
            endpoints = new[]
            {
                "GET  /  (this)",
                "GET  /ui  (HTML control panel)",
                "GET  /participants",
                "GET  /ws  (WebSocket: live participant snapshots)",
                "POST /participants/{id}/iso",
                "POST /participants/iso (body: displayName + enabled)",
                "POST /presets/{name}/apply",
                "POST /presets/refresh-discovery",
                "POST /presets/stop-all",
                "POST /teams/mute, /camera, /leave, /share, /raise-hand",
                "POST /recording (body: enabled + directory)",
                "POST /recording/marker (body: label)",
                "POST /notes (body: text)",
            },
        };
    }

    private static T? TryRead<T>(Func<T> reader) where T : class
    {
        try { return reader(); }
        catch { return null; }
    }

    private object GetParticipants()
    {
        var vm = _viewModel();
        if (vm is null) return new { participants = Array.Empty<object>() };
        // Synchronously snapshot on the UI thread — ObservableCollection isn't safe
        // to enumerate from this request handler's thread-pool task, and the
        // ParticipantViewModel property reads chase data-binding state.
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher is null) return new { participants = Array.Empty<object>() };
        var list = dispatcher.Invoke(() => vm.Participants.Select(p => (object)new
        {
            id = p.Id,
            displayName = p.DisplayName,
            isOnline = p.IsOnline,
            isEnabled = p.IsEnabled,
            customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
            stateLabel = p.StateLabel,
        }).ToArray());
        return new { participants = list };
    }

    private object RefreshDiscovery()
    {
        _controller.RefreshDiscovery();
        return new { ok = true, action = "refresh-discovery" };
    }

    private async Task<object> StopAllAsync()
    {
        var vm = _viewModel();
        if (vm is null) return new { ok = false, error = "view-model not ready" };
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" };

        // Snapshot the enabled set on the UI thread — ObservableCollection isn't
        // safe to enumerate from a thread-pool task, and reading the IsEnabled
        // property indirectly walks the data-binding system.
        var enabled = await dispatcher.InvokeAsync(() =>
            vm.Participants.Where(p => p.IsEnabled).ToArray());

        foreach (var p in enabled)
        {
            try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); }
            catch { /* defensive */ }
            await dispatcher.InvokeAsync(() => p.IsEnabled = false);
        }
        return new { ok = true, action = "stop-all", count = enabled.Length };
    }

    private object InvokeTeams(Func<TeamsControlBridge.InvokeResult> invoke, string action)
    {
        var result = invoke();
        return new
        {
            ok = result == TeamsControlBridge.InvokeResult.Invoked,
            action,
            result = result.ToString(),
        };
    }

    private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        var enabled = TryGetBool(body, query, "enabled") ?? false;
        var directory = TryGetString(body, query, "directory");
        _controller.SetRecording(enabled, directory);
        return new { ok = true, recording = new { enabled, directory } };
    }

    private object DropMarker(JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        var label = TryGetString(body, query, "label")
            ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss");
        _controller.AddRecordingMarker(label);
        return new { ok = true, action = "marker", label };
    }

    private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        var text = TryGetString(body, query, "text");
        if (string.IsNullOrWhiteSpace(text))
            return new { ok = false, error = "text required" };
        var ok = NotesService.Append(text);
        return new { ok, action = "note", path = NotesService.TodayPath };
    }

    /// <summary>
    /// Roll every active recording into a new chunk. Same code path as the UI's
    /// RollRecordingCommand — disable + brief delay + re-enable each pipeline.
    /// We marshal the participants snapshot through the dispatcher because
    /// ObservableCollection isn't safe to enumerate from the request thread.
    /// </summary>
    private async Task<object> RollRecordingAsync()
    {
        var vm = _viewModel();
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (vm is null || dispatcher is null)
            return new { ok = false, error = "view-model not ready" };

        var enabled = await dispatcher.InvokeAsync(() =>
            vm.Participants.Where(p => p.IsEnabled).ToArray());
        var rolled = 0;
        foreach (var p in enabled)
        {
            try
            {
                await _controller.DisableIsoAsync(p.Id, CancellationToken.None);
                await Task.Delay(150);
                var nameToUse = string.IsNullOrWhiteSpace(p.CustomName)
                    ? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName)
                    : p.CustomName;
                bool? recordOverride = p.RecordToDisk ? null : false;
                await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None);
                rolled++;
            }
            catch { /* per-pipeline best-effort */ }
        }
        return new { ok = true, action = "roll-recording", rolled };
    }

    private async Task<object> ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        // path = /participants/<guid>/iso
        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
        if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso")
            return NotFound();
        if (!Guid.TryParse(segments[1], out var id))
            return new { ok = false, error = "invalid id" };
        return await ToggleByIdAsync(id, body, query);
    }

    private async Task<object> ToggleIsoByNameAsync(JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        var displayName = TryGetString(body, query, "displayName");
        if (string.IsNullOrWhiteSpace(displayName))
            return new { ok = false, error = "displayName required" };
        var vm = _viewModel();
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (vm is null || dispatcher is null)
            return new { ok = false, error = "view-model not ready" };
        var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x =>
            string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase)));
        if (p is null) return new { ok = false, error = "participant not found", displayName };
        return await ToggleByIdAsync(p.Id, body, query);
    }

    private async Task<object> ToggleByIdAsync(Guid id, JsonElement body, System.Collections.Specialized.NameValueCollection query)
    {
        var enabled = TryGetBool(body, query, "enabled");
        var customName = TryGetString(body, query, "customName");
        var vm = _viewModel();
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (vm is null || dispatcher is null)
            return new { ok = false, error = "view-model not ready" };

        // Look up the VM and snapshot its current state on the UI thread —
        // ObservableCollection enumeration and view-model property reads both
        // need to happen there.
        var lookup = await dispatcher.InvokeAsync(() =>
        {
            var p = vm.Participants.FirstOrDefault(x => x.Id == id);
            return p is null
                ? null
                : new { Pvm = p, p.IsEnabled, p.CustomName };
        });
        if (lookup is null) return new { ok = false, error = "participant not found", id };

        var target = enabled ?? !lookup.IsEnabled;
        var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName;

        if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName))
            return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" };

        // Apply CustomName change first (if any) on the UI thread so a subsequent
        // EnableIsoAsync sees the new name.
        if (!string.IsNullOrEmpty(customName))
            await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName);

        if (target)
        {
            await _controller.EnableIsoAsync(id,
                string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse,
                CancellationToken.None);
            await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true);
        }
        else
        {
            await _controller.DisableIsoAsync(id, CancellationToken.None);
            await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false);
        }
        return new { ok = true, id, enabled = target };
    }

    private async Task<object> ApplyPresetAsync(string path)
    {
        // path = /presets/<name>/apply
        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
        if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply")
            return NotFound();
        var name = Uri.UnescapeDataString(segments[1]);
        var preset = OperatorPresetStore.Find(name);
        if (preset is null) return new { ok = false, error = "preset not found", name };

        var vm = _viewModel();
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        if (vm is null || dispatcher is null)
            return new { ok = false, error = "view-model not ready" };

        // Snapshot participants on the UI thread — ObservableCollection enumeration
        // and ParticipantViewModel state reads both need to happen there.
        // PresetApplier marshals subsequent property writes via the dispatcher.
        var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList());

        var result = await PresetApplier.ApplyAsync(
            preset, snapshot, _controller, dispatcher);

        return new
        {
            ok = true,
            name = preset.Name,
            matched = result.Matched,
            changed = result.Changed,
            skipped = result.Skipped,
        };
    }

    [SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")]
    private object NotFound() => new { error = "not found" };

    // ─── WebSocket push ─────────────────────────────────────────────────

    /// <summary>
    /// Owns a single client connection until it closes. Sends an immediate
    /// snapshot on connect (so the client doesn't have to wait up to 250ms
    /// for the next push tick), then sits in a receive loop draining any
    /// incoming text — we ignore client→server messages for v1 since all
    /// commands are REST. The receive loop is the canonical way to detect
    /// graceful close: when WebSocket.ReceiveAsync returns CloseReceived,
    /// we close back and remove the client.
    /// </summary>
    private async Task HandleWebSocketAsync(WebSocket ws)
    {
        var clientId = Guid.NewGuid();
        _clients[clientId] = ws;
        _logger?.LogInformation("WebSocket client {Id} connected.", clientId);

        try
        {
            // Initial snapshot — fetch synchronously on the UI thread so the
            // ObservableCollection isn't enumerated cross-thread.
            await SendAsync(ws, await GetSnapshotJsonAsync());

            var buf = new byte[1024];
            while (ws.State == WebSocketState.Open)
            {
                var result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), CancellationToken.None);
                if (result.MessageType == WebSocketMessageType.Close)
                {
                    await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
                    break;
                }
                // Ignore any client-sent messages for now; future bidirectional
                // commands could route through here.
            }
        }
        catch (WebSocketException) { /* client crashed; drop */ }
        catch (ObjectDisposedException) { /* Stop() aborted us */ }
        catch (OperationCanceledException) { /* server shutting down */ }
        finally
        {
            _clients.TryRemove(clientId, out _);
            // Don't double-dispose: Stop() already disposed the WebSocket if it's
            // tearing us down. Aborting an already-disposed socket is a no-op
            // throw which we catch + ignore.
            try { ws.Dispose(); } catch { /* defensive */ }
            _logger?.LogInformation("WebSocket client {Id} disconnected.", clientId);
        }
    }

    /// <summary>
    /// Dispatcher-tick handler. Reads the current participants snapshot, and if
    /// it differs from what we last pushed, broadcasts the new JSON to every
    /// connected client. Diffing on the JSON string is cheap and saves wire
    /// bytes when nothing's actually changing — typical operator workflow has
    /// long periods of no state churn between meetings.
    /// </summary>
    private async Task PushSnapshotIfChangedAsync()
    {
        if (_clients.IsEmpty) return;

        string snapshot;
        try { snapshot = await GetSnapshotJsonAsync(); }
        catch { return; }

        if (snapshot == _lastPushedSnapshot) return;
        _lastPushedSnapshot = snapshot;

        var bytes = Encoding.UTF8.GetBytes(snapshot);
        foreach (var (id, ws) in _clients.ToArray())
        {
            if (ws.State != WebSocketState.Open)
            {
                _clients.TryRemove(id, out _);
                continue;
            }
            try
            {
                await ws.SendAsync(
                    new ArraySegment<byte>(bytes),
                    WebSocketMessageType.Text,
                    endOfMessage: true,
                    CancellationToken.None);
            }
            catch
            {
                _clients.TryRemove(id, out _);
                try { ws.Dispose(); } catch { /* defensive */ }
            }
        }
    }

    private static async Task SendAsync(WebSocket ws, string text)
    {
        var bytes = Encoding.UTF8.GetBytes(text);
        await ws.SendAsync(
            new ArraySegment<byte>(bytes),
            WebSocketMessageType.Text,
            endOfMessage: true,
            CancellationToken.None);
    }

    /// <summary>
    /// Build the same payload as <c>GET /participants</c> but as a JSON string
    /// for direct WebSocket Send. Reads the ObservableCollection via the UI
    /// dispatcher because WPF's ObservableCollection isn't thread-safe to
    /// enumerate from a non-UI thread.
    /// </summary>
    private async Task<string> GetSnapshotJsonAsync()
    {
        var dispatcher = System.Windows.Application.Current?.Dispatcher;
        var participants = dispatcher is null
            ? Array.Empty<object>()
            : await dispatcher.InvokeAsync(() =>
                {
                    var vm = _viewModel();
                    if (vm is null) return Array.Empty<object>();
                    return vm.Participants.Select(p => (object)new
                    {
                        id = p.Id,
                        displayName = p.DisplayName,
                        isOnline = p.IsOnline,
                        isEnabled = p.IsEnabled,
                        customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName,
                        stateLabel = p.StateLabel,
                    }).ToArray();
                });
        return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts);
    }

    // ─── helpers ────────────────────────────────────────────────────────

    private static async Task<JsonElement> ReadBodyAsync(HttpListenerRequest req)
    {
        if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default;
        using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8);
        var raw = await sr.ReadToEndAsync();
        if (string.IsNullOrWhiteSpace(raw)) return default;
        try
        {
            return JsonSerializer.Deserialize<JsonElement>(raw);
        }
        catch
        {
            return default;
        }
    }

    private static async Task WriteJsonAsync(HttpListenerResponse res, object payload)
    {
        res.ContentType = "application/json; charset=utf-8";
        var json = JsonSerializer.Serialize(payload, JsonOpts);
        var bytes = Encoding.UTF8.GetBytes(json);
        res.ContentLength64 = bytes.Length;
        await res.OutputStream.WriteAsync(bytes);
    }

    private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
    {
        if (body.ValueKind == JsonValueKind.Object &&
            body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False)
            return v.GetBoolean();
        var q = query[key];
        if (q is null) return null;
        return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1";
    }

    private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key)
    {
        if (body.ValueKind == JsonValueKind.Object &&
            body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
            return v.GetString();
        return query[key];
    }
}
