using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using Mbproxy.Options;
using Mbproxy.Proxy;

namespace Mbproxy.Admin;

/// <summary>
/// Owns the Kestrel-backed admin HTTP endpoint. Driven by <see cref="Proxy.ProxyWorker"/>
/// (which calls <see cref="StartAsync"/> after listeners are up and <see cref="StopAsync"/>
/// at the END of graceful shutdown — supervisors stop and drain first, admin stops last).
///
/// <para>Lifecycle:</para>
/// <list type="bullet">
///   <item><see cref="StartAsync"/> builds a <see cref="WebApplication"/> bound to
///         <c>Mbproxy.AdminPort</c> and starts it non-blocking.</item>
///   <item>If the bind fails (port in use, etc.), logs <c>mbproxy.admin.bind.failed</c>
///         at Error and continues — the proxy listeners are unaffected.</item>
///   <item>If <c>AdminPort</c> changes via hot-reload, the current app is stopped and a
///         new one is started on the new port. Other config changes are ignored here.</item>
///   <item><see cref="StopAsync"/> shuts down the current Kestrel app with a 2 s deadline.</item>
/// </list>
///
/// <para>Routes: <c>GET /</c> and <c>GET /plc/{name}</c> (embedded SPA shells),
/// <c>GET /assets/{path}</c> (embedded Bootstrap / SignalR / fonts / app JS+CSS),
/// <c>GET /status.json</c> (JSON snapshot for scrapers), and the SignalR hub at
/// <c>/hub/status</c> driving the live dashboard feed.</para>
///
/// <para>Registered as a plain singleton (not <see cref="IHostedService"/>) so
/// <see cref="Proxy.ProxyWorker"/> can drive its lifecycle explicitly. This is required to
/// honour the design contract that the in-flight drain finishes BEFORE admin stops; an
/// IHostedService registration would let the host stop admin in reverse-registration order
/// and break that ordering.</para>
/// </summary>
internal sealed partial class AdminEndpointHost : IAsyncDisposable
{
    private readonly IOptionsMonitor<MbproxyOptions> _optionsMonitor;
    private readonly StatusSnapshotBuilder _builder;
    private readonly TagCaptureRegistry _captureRegistry;
    private readonly PlcSubscriptionTracker _subscriptionTracker;
    private readonly ILoggerFactory _loggerFactory;
    private readonly ILogger<AdminEndpointHost> _logger;

    // The currently-running Kestrel app; null when stopped or when bind failed.
    private WebApplication? _app;

    // SignalR push loop for the live dashboard. Lifecycle is tied to _app: created
    // when the Kestrel app starts, stopped (and captures disarmed) before it stops.
    private StatusBroadcaster? _broadcaster;

    // Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
    private readonly SemaphoreSlim _lock = new(1, 1);

    // Idempotency flag for DisposeAsync. ProxyWorker.StopAsync calls our StopAsync
    // explicitly; the DI container then disposes the singleton on host shutdown. Without
    // this flag the second pass would Dispose `_lock` twice and re-dispose the change
    // registration (both currently safe but symmetry with PlcMultiplexer prevents future
    // regression).
    private volatile bool _disposed;

    // Current configured port — used to detect changes on hot-reload.
    private int _currentPort;

    // Subscription token for IOptionsMonitor.OnChange.
    private IDisposable? _optionsChangeRegistration;

    public AdminEndpointHost(
        IOptionsMonitor<MbproxyOptions> optionsMonitor,
        StatusSnapshotBuilder builder,
        TagCaptureRegistry captureRegistry,
        PlcSubscriptionTracker subscriptionTracker,
        ILoggerFactory loggerFactory)
    {
        _optionsMonitor      = optionsMonitor;
        _builder             = builder;
        _captureRegistry     = captureRegistry;
        _subscriptionTracker = subscriptionTracker;
        _loggerFactory       = loggerFactory;
        _logger              = loggerFactory.CreateLogger<AdminEndpointHost>();
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _currentPort = _optionsMonitor.CurrentValue.AdminPort;

        await StartAppAsync(_currentPort, cancellationToken).ConfigureAwait(false);

        // Subscribe to config changes: if AdminPort changes, re-bind.
        _optionsChangeRegistration = _optionsMonitor.OnChange(opts =>
        {
            // Short-circuit if disposal has already started. The OnChange callback can
            // fire (and the Task.Run can be queued) AFTER StopAsync disposed the change
            // registration but BEFORE DI ran DisposeAsync; without this guard the lambda
            // would resurrect a fresh Kestrel app on the new port after the host already
            // considered admin shut down.
            if (_disposed) return;

            int newPort = opts.AdminPort;
            if (newPort == _currentPort) return; // Only care about AdminPort changes.

            // Fire-and-forget: re-bind is async; we can't await in OnChange.
            _ = Task.Run(async () =>
            {
                // Re-check after the queue: a concurrent StopAsync/DisposeAsync may have
                // landed between OnChange firing and the threadpool picking us up.
                if (_disposed) return;

                try
                {
                    await _lock.WaitAsync().ConfigureAwait(false);
                }
                catch (ObjectDisposedException)
                {
                    // _lock disposed mid-queue — host is shutting down. Drop silently.
                    return;
                }
                try
                {
                    if (_disposed) return;
                    if (newPort == _currentPort) return; // double-check under lock

                    // Stop the old app.
                    await StopCurrentAppAsync().ConfigureAwait(false);

                    _currentPort = newPort;

                    // Start on the new port.
                    await StartAppAsync(newPort, CancellationToken.None).ConfigureAwait(false);
                }
                finally
                {
                    try { _lock.Release(); }
                    catch (ObjectDisposedException) { /* dispose race */ }
                }
            });
        });
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _optionsChangeRegistration?.Dispose();
        _optionsChangeRegistration = null;

        await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            await StopCurrentAppAsync().ConfigureAwait(false);
        }
        finally
        {
            _lock.Release();
        }
    }

    // ── Internal helpers ─────────────────────────────────────────────────────

    /// <summary>
    /// Builds and starts a Kestrel <see cref="WebApplication"/> on <paramref name="port"/>.
    /// On bind failure, logs the error and sets <c>_app = null</c> — does NOT throw.
    /// Caller must hold <c>_lock</c> or be in a single-threaded context (StartAsync).
    /// </summary>
    private async Task StartAppAsync(int port, CancellationToken ct)
    {
        try
        {
            // Use CreateSlimBuilder with explicit args (empty) to avoid inheriting
            // process-level environment variables like ASPNETCORE_URLS.
            var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
            {
                Args = [],
            });

            // Suppress Kestrel/ASP.NET Core built-in logging; forward to the outer host's
            // logger factory so that admin-endpoint errors appear in the proxy's log stream.
            builder.Logging.ClearProviders();
            builder.Logging.AddProvider(new ForwardingLoggerProvider(_loggerFactory));

            // Explicit Kestrel listen — overrides any ASPNETCORE_URLS that leaked in.
            builder.WebHost.UseKestrel(k =>
            {
                k.Listen(System.Net.IPAddress.Any, port);
            });

            // SignalR hub for the live dashboard. The inner WebApplication has its own
            // DI container, so the singletons StatusHub depends on are re-registered here.
            // camelCase payloads keep the wire shape identical to GET /status.json.
            builder.Services
                .AddSignalR()
                .AddJsonProtocol(o =>
                    o.PayloadSerializerOptions.PropertyNamingPolicy =
                        System.Text.Json.JsonNamingPolicy.CamelCase);
            builder.Services.AddSingleton(_captureRegistry);
            builder.Services.AddSingleton(_subscriptionTracker);

            var app = builder.Build();

            // ── Routes ───────────────────────────────────────────────────────
            // GET /            — fleet dashboard SPA shell
            // GET /plc/{name}  — connection-detail SPA shell (name read client-side)
            // GET /assets/...  — embedded Bootstrap / SignalR / fonts / app JS+CSS
            // GET /status.json — unchanged JSON snapshot for scrapers
            // /hub/status      — SignalR hub for the live feed
            app.MapGet("/", (HttpContext ctx) => ServeHtmlShell(ctx, "index.html"));

            app.MapGet("/plc/{name}", (string name, HttpContext ctx) => ServeHtmlShell(ctx, "plc.html"));

            app.MapGet("/assets/{path}", (string path, HttpContext ctx) =>
            {
                // Flat asset directory — a path segment with a slash or "." traversal
                // can never match a resource, but reject it explicitly anyway.
                if (path.Contains('/') || path.Contains('\\') || path.Contains(".."))
                    return Results.NotFound();

                var bytes = ReadAssetCached(path);
                if (bytes is null)
                    return Results.NotFound();

                // Vendored assets are content-addressed by filename+version → immutable.
                ctx.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
                return Results.Bytes(bytes, ContentTypeFor(path));
            });

            app.MapGet("/status.json", (HttpContext ctx) =>
            {
                var snapshot = _builder.Build();
                string json  = JsonSerializer.Serialize(snapshot, StatusJsonContext.Default.StatusResponse);
                return Results.Content(json, "application/json");
            });

            app.MapHub<StatusHub>("/hub/status");

            await app.StartAsync(ct).ConfigureAwait(false);
            _app = app;

            // Start the SignalR push loop now that the hub is reachable.
            var hubContext = app.Services.GetRequiredService<IHubContext<StatusHub>>();
            _broadcaster = new StatusBroadcaster(
                new SignalRStatusPushSink(hubContext),
                _builder,
                _subscriptionTracker,
                _captureRegistry,
                _optionsMonitor,
                _loggerFactory.CreateLogger<StatusBroadcaster>());
            _broadcaster.Start();

            LogAdminStarted(_logger, port);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            // Bind failed — log and continue. Proxy listeners are unaffected.
            LogAdminBindFailed(_logger, port, ex.Message);
            _app = null;
        }
    }

    /// <summary>
    /// Stops the current <see cref="WebApplication"/> with a 2 s deadline, then disposes it.
    /// </summary>
    private async Task StopCurrentAppAsync()
    {
        // Stop the SignalR push loop first — this also disarms every tag-value capture,
        // so an AdminPort hot-reload that tears down this app never leaves one armed.
        if (_broadcaster is { } broadcaster)
        {
            _broadcaster = null;
            try
            {
                await broadcaster.DisposeAsync().ConfigureAwait(false);
            }
            catch
            {
                // Best-effort.
            }
        }

        if (_app is null) return;

        var app = _app;
        _app = null;

        try
        {
            using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
            await app.StopAsync(stopCts.Token).ConfigureAwait(false);
        }
        catch
        {
            // Best-effort.
        }

        await app.DisposeAsync().ConfigureAwait(false);
    }

    // ── Embedded asset serving ───────────────────────────────────────────────

    private const string AssetResourcePrefix = "Mbproxy.Admin.wwwroot.";

    // Embedded resources are immutable for the process lifetime; cache the decoded
    // bytes so a 232 KB Bootstrap stylesheet is not re-materialised per request.
    // `byte[]?` value so a known-miss is cached too. Static — shared across app rebuilds.
    private static readonly ConcurrentDictionary<string, byte[]?> AssetCache = new(StringComparer.Ordinal);

    /// <summary>
    /// Serves an embedded HTML shell (<c>index.html</c> / <c>plc.html</c>) with a
    /// <c>no-cache</c> header so a redeployed UI is picked up on the next load.
    /// </summary>
    private static IResult ServeHtmlShell(HttpContext ctx, string fileName)
    {
        var bytes = ReadAssetCached(fileName);
        if (bytes is null)
            return Results.NotFound();

        ctx.Response.Headers.CacheControl = "no-cache";
        return Results.Bytes(bytes, "text/html; charset=utf-8");
    }

    /// <summary>Reads an embedded <c>wwwroot</c> asset, caching the bytes (and misses).</summary>
    private static byte[]? ReadAssetCached(string fileName)
        => AssetCache.GetOrAdd(fileName, static name =>
        {
            var asm = typeof(AdminEndpointHost).Assembly;
            using var stream = asm.GetManifestResourceStream(AssetResourcePrefix + name);
            if (stream is null)
                return null;
            using var ms = new MemoryStream();
            stream.CopyTo(ms);
            return ms.ToArray();
        });

    private static string ContentTypeFor(string fileName)
        => Path.GetExtension(fileName).ToLowerInvariant() switch
        {
            ".html"  => "text/html; charset=utf-8",
            ".css"   => "text/css; charset=utf-8",
            ".js"    => "text/javascript; charset=utf-8",
            ".json"  => "application/json; charset=utf-8",
            ".woff2" => "font/woff2",
            ".svg"   => "image/svg+xml",
            ".ico"   => "image/x-icon",
            _        => "application/octet-stream",
        };

    // ── IAsyncDisposable ─────────────────────────────────────────────────────

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        _optionsChangeRegistration?.Dispose();
        _optionsChangeRegistration = null;

        if (_broadcaster is { } broadcaster)
        {
            _broadcaster = null;
            await broadcaster.DisposeAsync().ConfigureAwait(false);
        }

        if (_app is { } app)
        {
            _app = null;
            await app.DisposeAsync().ConfigureAwait(false);
        }

        try { _lock.Dispose(); } catch (ObjectDisposedException) { /* race-safe */ }
    }

    // ── Logging ──────────────────────────────────────────────────────────────

    [LoggerMessage(EventId = 70, EventName = "mbproxy.admin.started",
        Level = LogLevel.Information,
        Message = "Admin endpoint started on port {Port}")]
    private static partial void LogAdminStarted(ILogger logger, int port);

    [LoggerMessage(EventId = 71, EventName = "mbproxy.admin.bind.failed",
        Level = LogLevel.Error,
        Message = "Admin endpoint bind failed — admin page will be unavailable: Port={Port} Reason={Reason}")]
    private static partial void LogAdminBindFailed(ILogger logger, int port, string reason);

    // ── Inner logger provider (forwards Kestrel/ASP.NET logs to the proxy's factory) ────

    private sealed class ForwardingLoggerProvider : ILoggerProvider
    {
        private readonly ILoggerFactory _factory;
        public ForwardingLoggerProvider(ILoggerFactory factory) => _factory = factory;
        public ILogger CreateLogger(string categoryName) => _factory.CreateLogger(categoryName);
        public void Dispose() { }
    }
}
