using System.Security.Claims;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;

namespace DodoSSH.Api.Authorization;

/// <summary>Resolves the authenticated caller, provisioning them on first sight.</summary>
public interface ICurrentUserContext
{
    /// <summary>
    /// Returns the caller's account, creating it if this is their first authenticated request.
    /// </summary>
    /// <exception cref="InvalidOperationException">The request is not authenticated.</exception>
    Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}

/// <summary>
/// Turns pending team invitations addressed to a verified email into memberships.
/// </summary>
/// <remarks>
/// <para>
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
/// endpoint in the server depends on, growing a reference into one feature's folder.
/// </para>
/// </remarks>
public interface ITeamInvitationClaim
{
    /// <summary>
    /// Claims every live invitation addressed to <paramref name="email"/> for this account.
    /// </summary>
    /// <param name="user">The account signing in.</param>
    /// <param name="email">The address the token asserted, or null if it asserted none.</param>
    /// <param name="emailVerified">
    /// Whether the provider marked that address verified. False refuses the claim outright and is the
    /// whole of what stops an invitation being taken by anybody able to assert somebody else's
    /// address.
    /// </param>
    /// <param name="cancellationToken">Cancellation.</param>
    /// <returns>How many invitations became memberships.</returns>
    Task<int> ClaimAsync(
        UserAccount user,
        string? email,
        bool emailVerified,
        CancellationToken cancellationToken);
}

/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
/// <remarks>
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
/// email means anyone who can obtain a token bearing a victim's email address — from any configured
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
/// </remarks>
internal sealed class CurrentUserContext(
    IHttpContextAccessor accessor,
    DodoDbContext database,
    IOptions<Setup.OidcOptions> oidcOptions,
    ITeamInvitationClaim invitations,
    TimeProvider clock)
    : ICurrentUserContext
{
    /// <summary>
    /// How stale <see cref="UserAccount.LastSeenAtUtc"/> may get before a request refreshes it.
    /// </summary>
    /// <remarks>
    /// An hour, and coarse on purpose in both directions. Writing it on every request would put an
    /// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
    /// the xmin concurrency token — would start losing races between a user's own overlapping
    /// requests. Writing it never is what made the old "last active" column impossible to offer
    /// honestly. An hour answers the question a colleague actually asks, which is "this week or not",
    /// and it is also the window on which a pending invitation is swept for.
    /// </remarks>
    private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);

    private UserAccount? cached;

    /// <inheritdoc />
    public async Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken)
    {
        if (cached is not null)
        {
            return cached;
        }

        var principal = accessor.HttpContext?.User
            ?? throw new InvalidOperationException("No HTTP context is available.");

        if (principal.Identity?.IsAuthenticated != true)
        {
            throw new InvalidOperationException("The request is not authenticated.");
        }

        var issuer = RequireClaim(principal, "iss");
        var subject = RequireClaim(principal, "sub");
        var options = oidcOptions.Value;
        var email = principal.FindFirstValue(options.EmailClaim);
        var displayName = principal.FindFirstValue(options.NameClaim);
        var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);

        var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);

        if (existing is null)
        {
            cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
                .ConfigureAwait(false);

            // A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
            // rather than on the next hourly sweep — which would leave somebody staring at a team
            // list that does not yet contain the team they were told they had been added to.
            await invitations
                .ClaimAsync(cached, email, emailVerified, cancellationToken)
                .ConfigureAwait(false);

            return cached;
        }

        cached = existing;

        await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken)
            .ConfigureAwait(false);

        return cached;
    }

    /// <summary>
    /// Records that this account is active, and sweeps for invitations it can now claim.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The two are one operation because they want the same rate. Both are housekeeping nobody is
    /// waiting on, and doing them together costs one extra round trip per account per hour rather
    /// than two.
    /// </para>
    /// <para>
    /// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
    /// provisioning — or an invitation issued in the window between an account being created and this
    /// person next signing in — is picked up here instead of being stranded for ever.
    /// </para>
    /// <para>
    /// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
    /// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
    /// requests from one user would each read the row, each set the timestamp, and the second would
    /// fail on a version that had moved under it. This writes at most one row and cannot conflict.
    /// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
    /// nothing, and marking it modified would enlist the user row in whatever the request saves next.
    /// </para>
    /// </remarks>
    private async Task RefreshLastSeenAsync(
        UserAccount user,
        string? email,
        bool emailVerified,
        CancellationToken cancellationToken)
    {
        var now = clock.GetUtcNow();

        if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow)
        {
            return;
        }

        await database.Users
            .Where(u => u.Id == user.Id
                && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
            .ExecuteUpdateAsync(
                setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
                cancellationToken)
            .ConfigureAwait(false);

        await invitations
            .ClaimAsync(user, email, emailVerified, cancellationToken)
            .ConfigureAwait(false);
    }

    /// <remarks>
    /// A JWT boolean arrives as the string "true", so this parses rather than compares against a
    /// constant. Anything else — absent, "false", or a value this does not understand — is false,
    /// because the failure that matters is treating an unverified address as verified.
    /// </remarks>
    private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
        bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;

    private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
        database.Users.SingleOrDefaultAsync(
            u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
            cancellationToken);

    private async Task<UserAccount> ProvisionAsync(
        string issuer,
        string subject,
        string? email,
        string? displayName,
        CancellationToken cancellationToken)
    {
        var now = clock.GetUtcNow();

        var user = new UserAccount
        {
            Id = Guid.CreateVersion7(),
            Issuer = issuer,
            Subject = subject,
            Email = email,
            DisplayName = displayName,
            Status = UserStatus.Active,
            CreatedAtUtc = now,
            UpdatedAtUtc = now,
            LastSeenAtUtc = now,
        };

        database.Users.Add(user);

        try
        {
            await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            return user;
        }
        catch (DbUpdateException exception)
            when (string.Equals(
                (exception.InnerException as PostgresException)?.SqlState,
                PostgresErrorCodes.UniqueViolation,
                StringComparison.Ordinal))
        {
            // Two concurrent first requests from the same new user. The unique index on
            // (issuer, subject) is what makes this safe: one insert wins and the other reads it
            // back, rather than both proceeding with a duplicate account.
            database.Entry(user).State = EntityState.Detached;

            var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
            if (winner is not null)
            {
                return winner;
            }

            // The unique violation came from something other than the race we expected — for
            // instance the partial unique index on email. Do not swallow it.
            throw;
        }
    }

    private static string RequireClaim(ClaimsPrincipal principal, string claimType) =>
        principal.FindFirstValue(claimType)
            ?? throw new InvalidOperationException(
                $"The access token is missing the required '{claimType}' claim.");
}
