Editor/Server/HttpHost.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using HumanoidMocapX;

namespace HumanoidMocapHost;

/// <summary>Serves the capture API over the BCL <see cref="HttpListener"/>, for hosts that cannot
/// run ASP.NET, such as an s&amp;box editor. Routes, authentication, limits and status codes match
/// the ASP.NET entry in main.cs. Listens on loopback only: terminate HTTPS at a reverse proxy.
/// /v1/settings needs <see cref="ApiKeys.AdminKey"/>; every other /v1 route needs an owner key.</summary>
internal sealed class HttpHost : IAsyncDisposable
{
    /// <summary>The JSON conventions the X client and the ASP.NET entry use.</summary>
    internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
    const long MaximumOptionsBytes = 64 * 1024;
    // Small JSON bodies have no reason to trickle in; uploads keep the queue's 30-minute limit.
    static readonly TimeSpan BodyTimeout = TimeSpan.FromSeconds(15);
    readonly HttpListener listener = new();
    readonly ApiKeys keys;
    readonly CaptureQueue queue;
    readonly Action<Exception> reportError;
    readonly CancellationTokenSource stopping = new();
    readonly HashSet<Task> handlers = new();
    readonly Task accepting;

    /// <summary>Starts listening on <paramref name="prefix"/>, for example http://127.0.0.1:5080/.
    /// Unexpected request failures go to <paramref name="reportError"/>. Rejects non-loopback
    /// prefixes; throws <see cref="HttpListenerException"/> when the port is unavailable.</summary>
    internal HttpHost(string prefix, ApiKeys keys, CaptureQueue queue, Action<Exception> reportError)
    {
        if (!Uri.TryCreate(prefix, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttp || !uri.IsLoopback
            || uri.AbsolutePath != "/" || !prefix.EndsWith('/'))
            throw new ArgumentException("The host listens on a loopback http://host:port/ prefix; publish it through an HTTPS reverse proxy.", nameof(prefix));
        this.keys = keys; this.queue = queue; this.reportError = reportError;
        listener.Prefixes.Add(prefix);
        listener.Start();
        accepting = AcceptAsync();
    }

    async Task AcceptAsync()
    {
        while (!stopping.IsCancellationRequested)
        {
            HttpListenerContext context;
            try { context = await listener.GetContextAsync(); }
            catch (Exception) when (stopping.IsCancellationRequested) { return; }
            catch (Exception error) { reportError(error); return; }
            var handler = HandleAsync(context);
            lock (handlers) handlers.Add(handler);
            _ = handler.ContinueWith(done => { lock (handlers) handlers.Remove(done); }, TaskScheduler.Default);
        }
    }

    // Never faults: a client that disconnects mid-response only loses its own reply.
    async Task HandleAsync(HttpListenerContext context)
    {
        var response = context.Response;
        try { await RespondAsync(context.Request, response); }
        catch (Exception error) when (error is HttpListenerException or IOException or ObjectDisposedException
            || error is OperationCanceledException && stopping.IsCancellationRequested) { }
        catch (Exception error)
        {
            reportError(error);
            try { response.StatusCode = 500; } catch (InvalidOperationException) { }
        }
        finally
        {
            try { response.Close(); } catch (Exception error) when (error is HttpListenerException or ObjectDisposedException) { }
        }
    }

    async Task RespondAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        response.Headers["Cache-Control"] = "no-store";
        try
        {
            await RouteAsync(request, response);
        }
        catch (KeyNotFoundException) { response.StatusCode = 404; }
        catch (LimitExceededException error) { await Problem(response, 413, error.Message); }
        catch (JsonException) { await Problem(response, 400, "The request body is not valid JSON for this route."); }
        catch (Exception error) when (error is ArgumentException or InvalidDataException) { await Problem(response, 400, error.Message); }
        catch (IOException error)
        {
            reportError(error);
            await Problem(response, 500, "The processing host could not read or write job data. Contact the provider with the job ID.");
        }
        catch (QueueCapacityException error)
        {
            response.Headers["Retry-After"] = "30";
            await Problem(response, 429, error.Message);
        }
        catch (InvalidOperationException error) when (error is not ObjectDisposedException) { await Problem(response, 409, error.Message); }
        catch (OperationCanceledException) when (!stopping.IsCancellationRequested)
        {
            await Problem(response, 409, "The capture was cancelled during the upload.");
        }
    }

    async Task RouteAsync(HttpListenerRequest request, HttpListenerResponse response)
    {
        var path = request.Url!.AbsolutePath; var method = request.HttpMethod;
        if (path == "/health")
        {
            if (method != "GET") { response.StatusCode = 405; return; }
            await Write(response, 200, new { status = "ok" }); return;
        }
        if (path != "/v1" && !path.StartsWith("/v1/", StringComparison.Ordinal)) { response.StatusCode = 404; return; }
        // Compressed bodies could expand past every byte limit, so only identity encoding is accepted.
        if (request.Headers["Content-Encoding"] is { Length: > 0 } encoding && !encoding.Equals("identity", StringComparison.OrdinalIgnoreCase))
        { response.StatusCode = 415; return; }
        var authorization = request.Headers["Authorization"] ?? "";
        if (path == "/v1/settings")
        {
            if (!keys.IsAdmin(authorization)) { response.StatusCode = 401; return; }
            if (method == "PUT") queue.SetConcurrentJobs((await ReadBody<HostSettings>(request)).ConcurrentJobs);
            else if (method != "GET") { response.StatusCode = 405; return; }
            await Write(response, 200, Settings(queue));
            return;
        }
        if (keys.Owner(authorization) is not { } owner) { response.StatusCode = 401; return; }
        var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
        switch (segments)
        {
            case ["v1", "capabilities"]:
                if (method != "GET") { response.StatusCode = 405; return; }
                await Write(response, 200, queue.Capabilities());
                return;
            case ["v1", "jobs"]:
                if (method == "GET") { await Write(response, 200, queue.List(owner)); return; }
                if (method != "POST") { response.StatusCode = 405; return; }
                var job = await ReadBody<HostJobRequest>(request);
                var options = job.Options; var name = job.DisplayName();
                if (!queue.IsAvailable) { await Problem(response, 503, "The host's video processor is not configured."); return; }
                await Write(response, 201, queue.Create(owner, options, name, ApiKeys.IsSignedAccount(owner) ? job.Policy : null));
                return;
            case ["v1", "jobs", var text] when Guid.TryParse(text, out var id):
                if (method == "GET")
                {
                    var status = queue.Status(owner, id);
                    if (status.State is "queued" or "processing") response.Headers["Retry-After"] = "2";
                    await Write(response, 200, status);
                }
                else if (method == "DELETE") { queue.Cancel(owner, id); response.StatusCode = 204; }
                else response.StatusCode = 405;
                return;
            case ["v1", "jobs", var text, "video"] when Guid.TryParse(text, out var id):
                if (method != "PUT") { response.StatusCode = 405; return; }
                if (request.ContentLength64 > queue.MaximumUploadBytes) { response.StatusCode = 413; return; }
                await queue.UploadAsync(owner, id, request.Headers["X-Video-Extension"] ?? "", request.InputStream, stopping.Token);
                response.StatusCode = 202;
                return;
            case ["v1", "jobs", var text, "result"] when Guid.TryParse(text, out var id):
                if (method != "GET") { response.StatusCode = 405; return; }
                await using (var result = queue.OpenResult(owner, id))
                {
                    response.StatusCode = 200; response.ContentType = "application/json";
                    response.Headers["Content-Disposition"] = "attachment; filename=capture.hmotion";
                    response.ContentLength64 = result.Length;
                    await result.CopyToAsync(response.OutputStream, stopping.Token);
                }
                return;
            default:
                response.StatusCode = 404;
                return;
        }
    }

    /// <summary>The operator view of host settings, shared with the ASP.NET entry.</summary>
    internal static object Settings(CaptureQueue queue) => new { concurrentJobs = queue.ConcurrentJobs, maximumConcurrentJobs = CaptureQueue.MaximumConcurrentJobs };

    async Task<T> ReadBody<T>(HttpListenerRequest request)
    {
        using var body = new MemoryStream();
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token);
        timeout.CancelAfter(BodyTimeout);
        try { await CaptureQueue.CopyBounded(request.InputStream, body, MaximumOptionsBytes, timeout.Token); }
        catch (OperationCanceledException) when (!stopping.IsCancellationRequested)
        { throw new InvalidDataException("The request body took too long to arrive."); }
        body.Position = 0;
        return await JsonSerializer.DeserializeAsync<T>(body, Json) ?? throw new InvalidDataException("The request body is empty.");
    }

    static async Task Write<T>(HttpListenerResponse response, int status, T value)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(value, Json);
        response.StatusCode = status; response.ContentType = "application/json; charset=utf-8"; response.ContentLength64 = bytes.Length;
        await response.OutputStream.WriteAsync(bytes);
    }

    static Task Problem(HttpListenerResponse response, int status, string detail)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(new { title = ((HttpStatusCode)status).ToString(), status, detail }, Json);
        response.StatusCode = status; response.ContentType = "application/problem+json"; response.ContentLength64 = bytes.Length;
        return response.OutputStream.WriteAsync(bytes).AsTask();
    }

    /// <summary>Stops accepting requests, cancels in-flight uploads and waits for every handler.</summary>
    public async ValueTask DisposeAsync()
    {
        if (stopping.IsCancellationRequested) return;
        await stopping.CancelAsync();
        listener.Stop();
        await accepting;
        Task[] running;
        lock (handlers) running = handlers.ToArray();
        await Task.WhenAll(running);
        listener.Close(); stopping.Dispose();
    }
}