using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Session;

/// <summary>
/// Stands in for a token provider before anyone has signed in.
/// </summary>
/// <remarks>
/// Discovery has to happen before authentication is possible — a client cannot know how to authenticate
/// until it has asked — but the API client takes a token provider in its constructor. Rather than make
/// that provider mutable and hope no authenticated call slips through early, the unauthenticated phase
/// gets a provider that says exactly what went wrong.
/// </remarks>
internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
{
    internal static UnavailableAccessTokenProvider Instance { get; } = new();

    public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
        throw new InvalidOperationException(
            "An authenticated call was attempted before sign-in. Only /meta and the discovery document "
            + "are reachable at this point.");
}

/// <summary>
/// Keeps the bearer token fresh for the life of a connection.
/// </summary>
/// <remarks>
/// The refresh happens under a lock with the expiry re-checked inside it. Without that second check,
/// several concurrent calls all decide the token is stale and all refresh — and because many providers
/// rotate the refresh token on use, every attempt after the first fails, turning one expiry into a forced
/// re-authentication.
/// </remarks>
internal sealed class RefreshingAccessTokenProvider(
    OidcClient oidc,
    TokenSet initial,
    TimeProvider clock) : IAccessTokenProvider, IDisposable
{
    private readonly SemaphoreSlim gate = new(1, 1);
    private TokenSet tokens = initial;

    /// <summary>
    /// The refresh token this provider currently holds, or null when none was granted.
    /// </summary>
    /// <remarks>
    /// Read rather than raised as an event, because the one caller — the shell, persisting it so a later
    /// launch can resume — has a moment of its own to do that in and no interest in the instant a
    /// rotation happens. A volatile read of a reference the refresh path replaces wholesale: the value is
    /// either the old set or the new one, never a half-written one.
    /// </remarks>
    internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;

    public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
    {
        var current = Volatile.Read(ref tokens);

        if (!current.NeedsRefresh(clock))
        {
            return current.AccessToken;
        }

        await gate.WaitAsync(cancellationToken).ConfigureAwait(false);

        try
        {
            current = Volatile.Read(ref tokens);

            if (!current.NeedsRefresh(clock))
            {
                return current.AccessToken;
            }

            if (current.RefreshToken is null)
            {
                throw new InvalidOperationException(
                    "The access token has expired and no refresh token was granted. Sign in again.");
            }

            var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
                .ConfigureAwait(false);

            Volatile.Write(ref tokens, refreshed);

            return refreshed.AccessToken;
        }
        finally
        {
            gate.Release();
        }
    }

    public void Dispose() => gate.Dispose();
}

/// <summary>
/// Refuses to open anything, for the flows that must never reach a browser.
/// </summary>
/// <remarks>
/// <see cref="ServerConnection.ResumeAsync"/> uses only the refresh grant, which needs no user agent —
/// but <see cref="OidcClient"/> takes a launcher in its constructor because its other two flows do. This
/// makes "a resume never opens a browser" a property of the object rather than of the code path, so a
/// future call that wandered into an interactive flow would fail loudly here instead of surprising
/// somebody with a sign-in page that opened by itself.
/// </remarks>
internal sealed class NoBrowserLauncher : IBrowserLauncher
{
    internal static NoBrowserLauncher Instance { get; } = new();

    public Task OpenAsync(Uri url, CancellationToken cancellationToken) =>
        throw new InvalidOperationException(
            "This connection was resumed from a remembered sign-in and must not open a browser. "
            + "Signing in interactively is something the user asks for.");
}

/// <summary>
/// What a signed-in server offers, as everything above the session layer needs it.
/// </summary>
/// <remarks>
/// An interface rather than the concrete connection, for one specific reason: establishing a real one
/// requires discovery, a browser and a token exchange. A shell that depended on the concrete type would
/// make its own state machine — sign in, enroll, unlock, sync — reachable only by clicking through an
/// identity provider, which is the part of an application that most needs a test and least often has one.
/// </remarks>
public interface IVaultServer : IDisposable
{
    /// <summary>The server this is connected to.</summary>
    Uri ServerUrl { get; }

    /// <summary>Who am I, and publish my first key.</summary>
    IAccountApi Account { get; }

    /// <summary>Pull and push.</summary>
    ISyncApi Sync { get; }

    /// <summary>Teams, their members, and the vaults they own.</summary>
    ITeamApi Teams { get; }

    /// <summary>
    /// The public-key directory, and the key log that makes an answer from it checkable.
    /// </summary>
    /// <remarks>
    /// Exposed as one member because the two are only ever used together: a directory answer is a claim
    /// the server makes about somebody else's key, and the log is what turns it into something a client
    /// can verify. See <c>KeyLogAudit</c>.
    /// </remarks>
    IDirectoryApi Directory { get; }

    /// <summary>Vault key grants: who can open a vault, and who let them.</summary>
    IVaultGrantApi Grants { get; }

    /// <summary>
    /// Notices that something changed, so a synchronisation need not wait for the timer.
    /// </summary>
    /// <remarks>
    /// Always present, never null: a server that does not offer the feature — or a test standing in
    /// for one — supplies <see cref="IdleVaultEventStream"/>, which simply never delivers. That keeps
    /// every caller on one shape, because the correct behaviour without a socket is the behaviour
    /// with a silent one: synchronise on the timer. See ADR 0012.
    /// </remarks>
    IVaultEventStream Events { get; }

    /// <summary>Obtains the identity provider's signature over a key statement.</summary>
    IKeyBindingAuthorizer KeyBinding { get; }

    /// <summary>Sync tuning derived from what this server actually accepts.</summary>
    SyncOptions SyncOptions { get; }

    /// <summary>
    /// The refresh token this connection holds right now, or null when the provider granted none.
    /// </summary>
    /// <remarks>
    /// On the interface because remembering it is what lets a later launch come back online without a
    /// browser, and the thing doing the remembering — the shell — must not have to know whether it is
    /// holding a real connection or a test's stand-in. It changes over the life of a connection: a
    /// provider that rotates hands back a new one on every refresh, so a caller that persists this has
    /// to re-read it rather than cache it.
    /// </remarks>
    string? RefreshToken { get; }
}

/// <summary>
/// A signed-in connection to one DodoSSH server.
/// </summary>
/// <remarks>
/// <para>
/// The onboarding story in one object: the user types a server URL, the client reads
/// <c>/.well-known/dodossh-configuration</c> to learn the identity provider, the client id and the
/// scopes, and everything else follows. Nothing about the identity provider is configured on this
/// machine.
/// </para>
/// <para>
/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
/// syncing takes an <see cref="ISyncApi"/> per call rather than the session holding one.
/// </para>
/// </remarks>
public sealed class ServerConnection : IVaultServer
{
    private readonly HttpClient http;
    private readonly RefreshingAccessTokenProvider tokens;
    private bool disposed;

    private ServerConnection(
        Uri serverUrl,
        HttpClient http,
        DodoSshConfiguration configuration,
        MetaResponse meta,
        OidcClient oidc,
        RefreshingAccessTokenProvider tokens,
        DodoSshApiClient api,
        TimeProvider clock)
    {
        ServerUrl = serverUrl;
        this.http = http;
        Configuration = configuration;
        Meta = meta;
        Oidc = oidc;
        this.tokens = tokens;
        Api = api;

        // Decided from what this server said it supports rather than attempted and allowed to fail,
        // which is the same capability negotiation SyncOptions below does — see ADR 0002. A client
        // that dialled anyway would reconnect against a 404 for the whole session, and would look
        // from the outside exactly like one whose network was eating WebSockets.
        Events = meta.Features.Contains(VaultEvents.Feature, StringComparer.Ordinal)
            ? new VaultEventStream(serverUrl, tokens, clock)
            : IdleVaultEventStream.Instance;
    }

    /// <summary>The server this is connected to.</summary>
    public Uri ServerUrl { get; }

    /// <summary>What the server told us about itself and its identity provider.</summary>
    public DodoSshConfiguration Configuration { get; }

    /// <summary>Versions, features and limits.</summary>
    public MetaResponse Meta { get; }

    /// <summary>The identity provider client, which is also the key-binding authorizer.</summary>
    public OidcClient Oidc { get; }

    /// <summary>The authenticated API client.</summary>
    public DodoSshApiClient Api { get; }

    /// <inheritdoc />
    public IAccountApi Account => Api;

    /// <inheritdoc />
    public ISyncApi Sync => Api;

    /// <inheritdoc />
    public ITeamApi Teams => Api;

    /// <inheritdoc />
    public IDirectoryApi Directory => Api;

    /// <inheritdoc />
    public IVaultGrantApi Grants => Api;

    /// <inheritdoc />
    public IVaultEventStream Events { get; }

    /// <inheritdoc />
    public IKeyBindingAuthorizer KeyBinding => Oidc;

    /// <summary>
    /// Sync tuning derived from what this server actually accepts.
    /// </summary>
    /// <remarks>
    /// This is what capability negotiation is for, and why there is no URL API version. A client and a
    /// server that upgrade independently — normal for self-hosted software — have to agree on limits by
    /// asking rather than by assuming. Sending a batch larger than the server's cap would have the whole
    /// push rejected rather than the excess trimmed.
    /// </remarks>
    /// <inheritdoc cref="IVaultServer.SyncOptions" />
    public SyncOptions SyncOptions => new()
    {
        MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
    };

    /// <inheritdoc />
    public string? RefreshToken => tokens.RefreshToken;

    /// <summary>
    /// Discovers the server, signs the user in through their browser, and returns the connection.
    /// </summary>
    /// <param name="serverUrl">The DodoSSH server's base URL — the only thing the user has to know.</param>
    /// <param name="browser">Opens the system browser. Never an embedded one; see RFC 8252.</param>
    /// <param name="clock">Time source, for token expiry.</param>
    /// <param name="cancellationToken">Cancels the wait for the browser.</param>
    public static async Task<ServerConnection> SignInAsync(
        Uri serverUrl,
        IBrowserLauncher browser,
        TimeProvider clock,
        CancellationToken cancellationToken,
        Func<OidcClientOptions, OidcClientOptions>? configureOidc = null)
    {
        ArgumentNullException.ThrowIfNull(serverUrl);
        ArgumentNullException.ThrowIfNull(browser);
        ArgumentNullException.ThrowIfNull(clock);

        var transport = new HttpClient { BaseAddress = serverUrl };

        try
        {
            var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);

            var configuration = await discovery.GetConfigurationAsync(cancellationToken)
                .ConfigureAwait(false);

            var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);

            // The head gets a say in how the authorization response comes back, and in nothing else.
            // Everything that decides whether the flow is *safe* — PKCE, the state check, the discovery
            // document, the token exchange — is built from the server's own configuration above and is not
            // reachable from here. See OidcClientOptions.CallbackFactory.
            var oidcOptions = BuildOidcOptions(configuration);

            var oidc = new OidcClient(
                transport,
                browser,
                clock,
                configureOidc is null ? oidcOptions : configureOidc(oidcOptions));

            var tokenSet = await oidc.SignInAsync(cancellationToken).ConfigureAwait(false);

            var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);

            return new ServerConnection(
                serverUrl,
                transport,
                configuration,
                meta,
                oidc,
                refreshing,
                new DodoSshApiClient(transport, refreshing),
                clock);
        }
        catch
        {
            transport.Dispose();
            throw;
        }
    }

    /// <summary>
    /// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The difference between an application that is signed in and one that merely was. Without this, a
    /// machine that has been set up is offline from launch until somebody goes and presses a button —
    /// which means the sync loop, the outbox and a colleague's changes all wait on an action nobody has a
    /// reason to take.
    /// </para>
    /// <para>
    /// Discovery runs again rather than being cached, because the client is deliberately configured by the
    /// server: the authority, the client id and the scopes are read from
    /// <c>/.well-known/dodossh-configuration</c> at every connection, so a deployment that moves its
    /// identity provider does not leave every client pinned to the old one.
    /// </para>
    /// <para>
    /// It fails rather than falling back when the token has been revoked or has expired, and that is the
    /// point of passing a launcher that refuses: a resume must never quietly become an interactive
    /// sign-in, which from a user's side is a browser window that opens on its own. The caller's answer to
    /// a failure is to stay offline and forget the token.
    /// </para>
    /// </remarks>
    /// <param name="serverUrl">The server this profile is enrolled against.</param>
    /// <param name="refreshToken">The remembered token.</param>
    /// <param name="clock">Time source, for token expiry.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    public static async Task<ServerConnection> ResumeAsync(
        Uri serverUrl,
        string refreshToken,
        TimeProvider clock,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(serverUrl);
        ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
        ArgumentNullException.ThrowIfNull(clock);

        var transport = new HttpClient { BaseAddress = serverUrl };

        try
        {
            var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);

            var configuration = await discovery.GetConfigurationAsync(cancellationToken)
                .ConfigureAwait(false);

            var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);

            var oidc = new OidcClient(
                transport, NoBrowserLauncher.Instance, clock, BuildOidcOptions(configuration));

            var tokenSet = await oidc.RefreshAsync(refreshToken, cancellationToken).ConfigureAwait(false);

            var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);

            return new ServerConnection(
                serverUrl,
                transport,
                configuration,
                meta,
                oidc,
                refreshing,
                new DodoSshApiClient(transport, refreshing),
                clock);
        }
        catch
        {
            transport.Dispose();
            throw;
        }
    }

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

        disposed = true;

        // First, and without waiting: the socket's own loops read the token provider and the transport
        // below, so tearing either down while it is still dialling would surface as a fault on a
        // background thread at the moment a user signed out.
        Events.Dispose();

        tokens.Dispose();
        http.Dispose();
    }

    /// <remarks>
    /// HTTPS is required for the provider's metadata unless the authority is loopback, which is what a
    /// development Keycloak looks like. A configuration flag would be the alternative and a worse one:
    /// it would be set once during development and never unset. Loopback is not a weaker channel — it
    /// never leaves the machine — so the exemption is narrow and does not need a switch.
    /// </remarks>
    private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
        new()
        {
            Authority = configuration.Oidc.Authority,
            ClientId = configuration.Oidc.ClientId,
            Scopes = configuration.Oidc.Scopes,
            RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
        };
}
