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>
/// 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,
    TimeProvider clock)
    : ICurrentUserContext
{
    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);

        cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
            ?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
                .ConfigureAwait(false);

        return cached;
    }

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