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 DragonISO.App.ViewModels;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;

namespace DragonISO.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 Dragon-ISO 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 Dragon-ISO".
/// 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>
// Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/.
// This file holds the host: listener lifecycle, accept loop, dispatch table,
// response helpers, and the WebSocket push loop.
public sealed partial 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 Dragon-ISO 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;
            }

            // GET /participants/{id}/thumbnail.bmp â€” small BMP of the latest
            // processed frame. Returns 404 when no pipeline is running for
            // this participant. The HTML control panel uses this URL with
            // a cache-busting query param every ~1s to drive live preview
            // tiles. BMP (not JPEG) because WPF imaging types NRE from
            // non-UI threads and BMP encodes in plain managed code; the
            // 40KB payload at 192-wide compresses fine over LAN gzip.
            // Old /thumbnail.jpg URL accepted for backward compat.
            if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal)
                && (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal)))
            {
                var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg";
                var idSegment = path.AsSpan("/participants/".Length,
                    path.Length - "/participants/".Length - ext.Length).ToString();
                if (!Guid.TryParse(idSegment, out var thumbId))
                {
                    res.StatusCode = 400;
                    await WriteJsonAsync(res, new { error = "invalid id" });
                    return;
                }
                var bmp = TryEncodeThumbnailJpeg(thumbId);
                if (bmp is null)
                {
                    res.StatusCode = 404;
                    await WriteJsonAsync(res, new { error = "no frame", id = thumbId });
                    return;
                }
                res.ContentType = "image/bmp";
                res.AddHeader("Cache-Control", "no-store, must-revalidate");
                res.ContentLength64 = bmp.Length;
                await res.OutputStream.WriteAsync(bmp);
                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"),
                // /recording routes removed alongside the rest of the recording surface.
                // Topology â€” read the machine NDI config to report whether raw
                // Teams NDI sources are hidden from the LAN, and let the
                // operator apply / restore without leaving the web UI.
                ("GET",  "/topology")           => GetTopology(),
                ("POST", "/topology/apply")     => await ApplyTopologyAsync(),
                ("POST", "/topology/restore")   => await RestoreTopologyAsync(),
                _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal)
                                                && path.EndsWith("/override", StringComparison.Ordinal)
                                                => await SetIsoOverrideByIdAsync(path, body),
                _ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal)
                                                && path.EndsWith("/override", StringComparison.Ordinal)
                                                => await ClearIsoOverrideByIdAsync(path),
                ("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 â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    //
    // Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as
    // partials of this class. See HomeEndpoints, ParticipantsEndpoints,
    // PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints,
    // and ThumbnailEndpoint. The WebSocket push surface is at
    // Services/ControlSurface/WebSocketHub.cs.

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

    // â”€â”€â”€ 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];
    }
}
