using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Text;

namespace DodoSSH.Client.Auth;

/// <summary>The query parameters an authorization callback delivered.</summary>
/// <param name="Parameters">Every query parameter, decoded.</param>
public sealed record CallbackResult(IReadOnlyDictionary<string, string> Parameters)
{
    /// <summary>The authorization code, when the callback carried one.</summary>
    public string? Code => Parameters.GetValueOrDefault("code");

    /// <summary>The state value echoed back, for the caller to compare.</summary>
    public string? State => Parameters.GetValueOrDefault("state");

    /// <summary>The OAuth error code, when the provider refused.</summary>
    public string? Error => Parameters.GetValueOrDefault("error");

    /// <summary>The provider's human-readable error description.</summary>
    public string? ErrorDescription => Parameters.GetValueOrDefault("error_description");
}

/// <summary>
/// A single-use HTTP listener on an ephemeral loopback port, for the authorization redirect.
/// </summary>
/// <remarks>
/// <para>
/// RFC 8252 §7.3: a native client receives the redirect on a loopback interface, using the system
/// browser rather than an embedded one so the user can see the real address bar and the provider's
/// own certificate. A custom URI scheme is the alternative and is worse — any application on the
/// machine can claim a scheme, whereas only one process can hold a port.
/// </para>
/// <para>
/// Raw <see cref="TcpListener"/> rather than <c>HttpListener</c>, for three reasons: an ephemeral
/// port can be bound and read atomically instead of picking a port and hoping it is still free a
/// moment later; there is no HTTP.SYS URL-ACL question on Windows; and the entire surface is one
/// request line, which is easier to reason about than a general-purpose HTTP server.
/// </para>
/// <para>
/// The literal <c>127.0.0.1</c> is used rather than <c>localhost</c>. RFC 8252 §8.3 permits either,
/// but the name resolves through the hosts file and DNS, and the address does not.
/// </para>
/// </remarks>
/// <summary>
/// Where the authorization response comes back to.
/// </summary>
/// <remarks>
/// <para>
/// An interface because the answer is a platform decision. A desktop uses a loopback redirect (RFC 8252
/// §7.3) because it has a real HTTP stack and no way to register a URI scheme; a phone must not, because
/// on a shared device any other application can bind a loopback port and race for the response — the
/// attack §8.3 names. Android registers a redirect with the system instead and is handed the response as
/// an intent.
/// </para>
/// <para>
/// <b>What does not vary is everything above it.</b> PKCE, the state check, discovery, the token exchange
/// and the key binding are the same on both, which is the whole point of putting the seam here rather than
/// giving the phone its own flow — a second implementation of an OIDC client is a second place for a
/// security bug to live.
/// </para>
/// </remarks>
public interface IAuthorizationCallback : IDisposable
{
    /// <summary>
    /// Where the provider should send the response.
    /// </summary>
    /// <remarks>
    /// Read before the browser opens and sent again with the token request, because the authorization
    /// server has to see the same value twice. A listener that chose its port lazily would hand out one
    /// URI and listen on another.
    /// </remarks>
    Uri RedirectUri { get; }

    /// <summary>Waits for the response.</summary>
    /// <param name="completionHtml">
    /// What to show in the browser once the response arrives. Ignored by an implementation that has no
    /// browser tab of its own to write into — the system closes the tab when the app takes the intent.
    /// </param>
    /// <param name="cancellationToken">Abandons the wait.</param>
    Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken);
}

public sealed class LoopbackCallbackListener : IAuthorizationCallback
{
    /// <summary>Longest request line and header block accepted, as a denial-of-service bound.</summary>
    private const int MaximumRequestBytes = 16 * 1024;

    private readonly TcpListener listener;
    private bool disposed;

    /// <summary>Starts listening on a free loopback port.</summary>
    /// <param name="path">The redirect path, including its leading slash.</param>
    public LoopbackCallbackListener(string path = "/callback")
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(path);

        if (!path.StartsWith('/'))
        {
            throw new ArgumentException("The redirect path must begin with '/'.", nameof(path));
        }

        Path = path;

        // Port 0 lets the OS pick, and the bound port is readable immediately — so there is no
        // window in which another process could take the port between choosing and binding it.
        listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();

        Port = ((IPEndPoint)listener.LocalEndpoint).Port;
    }

    /// <summary>The redirect path this listener answers on.</summary>
    public string Path { get; }

    /// <summary>The port the OS assigned.</summary>
    public int Port { get; }

    /// <summary>The exact redirect URI to send in the authorization request.</summary>
    /// <remarks>
    /// The token endpoint compares this byte-for-byte against what it saw at authorization time, so
    /// it must be built once and reused rather than reconstructed.
    /// </remarks>
    public Uri RedirectUri => new(
        string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{Path}"),
        UriKind.Absolute);

    /// <summary>
    /// Waits for the browser to arrive at the redirect.
    /// </summary>
    /// <remarks>
    /// Requests to any other path are answered 404 and ignored rather than ending the wait. A
    /// browser will happily ask for <c>/favicon.ico</c> first, and treating that as the callback
    /// would abort every sign-in.
    /// </remarks>
    /// <param name="completionHtml">The page shown in the browser once the code is captured.</param>
    /// <param name="cancellationToken">Cancels the wait — a user closing the sign-in dialog.</param>
    public async Task<CallbackResult> WaitForCallbackAsync(
        string completionHtml,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        while (true)
        {
            using var client = await listener
                .AcceptTcpClientAsync(cancellationToken)
                .ConfigureAwait(false);

            var stream = client.GetStream();
            await using var streamScope = stream.ConfigureAwait(false);

            var target = await ReadRequestTargetAsync(stream, cancellationToken).ConfigureAwait(false);
            if (target is null)
            {
                continue;
            }

            // Resolved against a fixed base purely to reuse the query parser; the authority is
            // never used.
            var requested = new Uri(new Uri("http://127.0.0.1/", UriKind.Absolute), target);

            if (!string.Equals(requested.AbsolutePath, Path, StringComparison.Ordinal))
            {
                await RespondAsync(stream, "404 Not Found", "<h1>Not found</h1>", cancellationToken)
                    .ConfigureAwait(false);
                continue;
            }

            await RespondAsync(stream, "200 OK", completionHtml, cancellationToken).ConfigureAwait(false);

            return new CallbackResult(ParseQuery(requested.Query));
        }
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;
        listener.Dispose();
    }

    /// <summary>Reads the request target from the request line, then drains the headers.</summary>
    /// <returns>The target, or <see langword="null"/> if the request was not a usable GET.</returns>
    private static async Task<string?> ReadRequestTargetAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        var buffer = new byte[MaximumRequestBytes];
        var count = 0;

        // Read until the end of the header block. The body is irrelevant: a redirect is a GET.
        while (count < buffer.Length)
        {
            var read = await stream
                .ReadAsync(buffer.AsMemory(count), cancellationToken)
                .ConfigureAwait(false);

            if (read == 0)
            {
                break;
            }

            count += read;

            if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
            {
                break;
            }
        }

        var text = Encoding.ASCII.GetString(buffer, 0, count);
        var firstLine = text.Split("\r\n", StringSplitOptions.None)[0];
        var parts = firstLine.Split(' ');

        return parts.Length >= 2 && string.Equals(parts[0], "GET", StringComparison.Ordinal)
            ? parts[1]
            : null;
    }

    private static async Task RespondAsync(
        Stream stream,
        string status,
        string html,
        CancellationToken cancellationToken)
    {
        var body = Encoding.UTF8.GetBytes(html);

        // Plain concatenation with an explicit invariant conversion. string.Create's interpolated
        // overload takes a handler by ref, so a concatenation of interpolated pieces does not bind
        // to it — and the header must stay byte-exact regardless of culture.
        var header =
            "HTTP/1.1 " + status + "\r\n"
            + "Content-Type: text/html; charset=utf-8\r\n"
            + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"

            // The page has served its purpose the moment it renders; caching it would leave the
            // authorization code's query string in the browser's cache.
            + "Cache-Control: no-store\r\n"
            + "Connection: close\r\n\r\n";

        await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
        await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
    }

    private static Dictionary<string, string> ParseQuery(string query)
    {
        var parameters = new Dictionary<string, string>(StringComparer.Ordinal);

        foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
        {
            var separator = pair.IndexOf('=', StringComparison.Ordinal);

            if (separator < 0)
            {
                parameters[Uri.UnescapeDataString(pair)] = string.Empty;
                continue;
            }

            var name = Uri.UnescapeDataString(pair[..separator]);
            var value = Uri.UnescapeDataString(pair[(separator + 1)..].Replace('+', ' '));

            parameters[name] = value;
        }

        return parameters;
    }
}
