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>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
/// anything that would.</b> This class used to claim pending team invitations on the way past, which
/// made a provider's assertion about an address into a decision about who joins a team; invitations are
/// gone and membership is granted only to an account somebody named — see
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
/// </para>
/// </remarks>
internal sealed class CurrentUserContext(
    IHttpContextAccessor accessor,
    DodoDbContext database,
    IOptions<Setup.OidcOptions> oidcOptions,
    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".
    /// </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 existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);

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

            return cached;
        }

        cached = existing;

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

        return cached;
    }

    /// <summary>
    /// Records that this account is active.
    /// </summary>
    /// <remarks>
    /// <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.
    /// </remarks>
    private async Task RefreshLastSeenAsync(UserAccount user, 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);
    }

    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.");
}
