using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Application.Security;
using Tiku.Infrastructure.Security;

namespace Tiku.Infrastructure.Auth;

public sealed class SmsVerificationService(
    TikuDbContext dbContext,
    ISmsProvider smsProvider,
    IRedisSecurityStore redisSecurityStore,
    IFeatureAccessService featureAccessService,
    IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
{
    public SmsVerificationService(
        TikuDbContext dbContext,
        ISmsProvider smsProvider,
        IFeatureAccessService featureAccessService,
        IOptions<SmsSecurityOptions> securityOptions)
        : this(dbContext, smsProvider, new NullRedisSecurityStore(), featureAccessService, securityOptions)
    {
    }

    private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
    private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
    private readonly SmsSecurityOptions options = securityOptions.Value;

    public async Task<SmsSendResult> CreateCodeAsync(
        SendSmsCodeRequest request,
        CancellationToken cancellationToken = default)
    {
        EnsureValidOptions();

        var phone = SmsCodeHashing.NormalizePhone(request.Phone);
        var now = DateTimeOffset.UtcNow;
        await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
        var quotaReserved = await featureAccessService.TryConsumeQuotaAsync(
            request.TenantId,
            SaasQuotaMetricCatalog.SmsCount,
            1,
            cancellationToken);
        if (!quotaReserved)
        {
            throw new FeatureAccessException(
                "Tenant SMS quota is exhausted.",
                "feature_quota_exhausted");
        }

        var code = RandomNumberGenerator
            .GetInt32(100000, 1000000)
            .ToString(CultureInfo.InvariantCulture);
        var codeHash = SmsCodeHashing.Hash(
            request.TenantId,
            phone,
            request.Purpose,
            code,
            options.CodePepper);

        SmsProviderSendResult sendResult;
        var providerAccepted = false;
        try
        {
            sendResult = await smsProvider.SendAsync(
                new SmsProviderSendRequest(
                    request.TenantId,
                    phone,
                    request.Purpose,
                    code,
                    request.IpAddress,
                    request.UserAgent),
                cancellationToken);
            providerAccepted = true;
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
            {
                TenantId = request.TenantId,
                Phone = phone,
                Purpose = request.Purpose,
                CodeHash = codeHash,
                Provider = "failed",
                Status = SmsVerificationStatus.Failed,
                ExpiresAt = now,
                IpAddress = request.IpAddress,
                UserAgent = request.UserAgent,
                Metadata = JsonDefaults.Object()
            });
            dbContext.AuthLoginEvents.Add(new AuthLoginEvent
            {
                TenantId = request.TenantId,
                Provider = "sms",
                Identifier = phone,
                Result = AuthLoginResult.Failed,
                FailureCode = "sms_provider_send_failed",
                IpAddress = request.IpAddress,
                UserAgent = request.UserAgent
            });
            await dbContext.SaveChangesAsync(cancellationToken);

            throw exception is SmsProviderException
                ? exception
                : new SmsProviderException(
                    "SMS provider failed to send the verification code.",
                    "sms_provider_send_failed",
                    exception);
        }
        finally
        {
            if (!providerAccepted)
            {
                await featureAccessService.ReleaseQuotaAsync(
                    request.TenantId,
                    SaasQuotaMetricCatalog.SmsCount,
                    1,
                    CancellationToken.None);
            }
        }

        await ExpirePreviousCodesAsync(
            request.TenantId,
            phone,
            request.Purpose,
            now,
            cancellationToken);

        var verification = new SmsVerificationCode
        {
            TenantId = request.TenantId,
            Phone = phone,
            Purpose = request.Purpose,
            CodeHash = codeHash,
            Provider = sendResult.Provider,
            Status = SmsVerificationStatus.Sent,
            ExpiresAt = now.Add(CodeLifetime),
            IpAddress = request.IpAddress,
            UserAgent = request.UserAgent
        };

        dbContext.SmsVerificationCodes.Add(verification);
        dbContext.AuthLoginEvents.Add(new AuthLoginEvent
        {
            TenantId = request.TenantId,
            Provider = "sms",
            Identifier = phone,
            Result = AuthLoginResult.Sent,
            IpAddress = request.IpAddress,
            UserAgent = request.UserAgent,
            Metadata = JsonSerializer.SerializeToElement(new
            {
                verificationId = verification.Id,
                sendResult.Provider,
                sendResult.Status,
                sendResult.MessageId,
                request.DeviceId
            })
        });
        await dbContext.SaveChangesAsync(cancellationToken);

        return new SmsSendResult(verification.Id, verification.ExpiresAt);
    }

    public async Task VerifyCodeAsync(
        Guid tenantId,
        string phone,
        SmsPurpose purpose,
        string code,
        CancellationToken cancellationToken = default)
    {
        EnsureValidOptions();

        var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
        var now = DateTimeOffset.UtcNow;
        await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken);
        var codeHash = SmsCodeHashing.Hash(
            tenantId,
            normalizedPhone,
            purpose,
            code,
            options.CodePepper);
        var verification = await dbContext.SmsVerificationCodes
            .AsNoTracking()
            .Where(entity =>
                entity.TenantId == tenantId &&
                entity.Phone == normalizedPhone &&
                entity.Purpose == purpose &&
                entity.ConsumedAt == null &&
                entity.Status == SmsVerificationStatus.Sent)
            .OrderByDescending(entity => entity.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

        if (verification is null)
        {
            throw new InvalidCredentialsException("invalid_sms_code");
        }

        if (verification.ExpiresAt <= now)
        {
            await MarkExpiredAsync(verification.Id, now, cancellationToken);
            throw new InvalidCredentialsException("invalid_sms_code");
        }

        if (HashesMatch(verification.CodeHash, codeHash))
        {
            var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
            if (consumed)
            {
                return;
            }

            throw new InvalidCredentialsException("invalid_sms_code");
        }

        await RecordFailedAttemptAsync(verification.Id, now, cancellationToken);
        throw new InvalidCredentialsException("invalid_sms_code");
    }

    private async Task ConsumeVerificationLimitAsync(
        Guid tenantId,
        string phone,
        SmsPurpose purpose,
        CancellationToken cancellationToken)
    {
        if (!redisSecurityStore.IsConfigured)
        {
            return;
        }

        var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone)))
            .ToLowerInvariant();
        try
        {
            var result = await redisSecurityStore.ConsumeAsync(
            [
                new DistributedRateLimitBucket(
                    $"sms-verify:{tenantId:N}:{purpose.ToString().ToLowerInvariant()}:{phoneHash}",
                    options.MaxVerificationAttempts,
                    CodeLifetime)
            ], cancellationToken);
            if (!result.Allowed)
            {
                throw new SmsRateLimitedException();
            }
        }
        catch (RedisSecurityUnavailableException)
        {
            throw new AuthSecurityUnavailableException();
        }
    }

    private async Task ConsumeRateLimitsAsync(
        SendSmsCodeRequest request,
        string phone,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        var limits = BuildRateLimits(request, phone);
        var bucketStart = TruncateToHour(now);

        if (redisSecurityStore.IsConfigured)
        {
            try
            {
                var distributed = await redisSecurityStore.ConsumeAsync(
                    limits.Select(limit => new DistributedRateLimitBucket(
                        $"sms-send:{request.TenantId:N}:{ToSnakeCase(limit.Dimension)}:{limit.ScopeHash}",
                        limit.Maximum,
                        TimeSpan.FromHours(1))).ToArray(),
                    cancellationToken);
                if (!distributed.Allowed)
                {
                    throw new SmsRateLimitedException();
                }
            }
            catch (RedisSecurityUnavailableException)
            {
                throw new AuthSecurityUnavailableException();
            }
        }

        if (!dbContext.Database.IsRelational())
        {
            await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
            return;
        }

        await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
        foreach (var limit in limits)
        {
            var dimension = ToSnakeCase(limit.Dimension);
            var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
                INSERT INTO sms_send_rate_limits
                    (tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
                VALUES
                    ({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
                ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
                DO UPDATE SET
                    request_count = sms_send_rate_limits.request_count + 1,
                    updated_at = EXCLUDED.updated_at
                WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
                """, cancellationToken);

            if (affected == 0)
            {
                await transaction.RollbackAsync(cancellationToken);
                throw new SmsRateLimitedException();
            }
        }

        await transaction.CommitAsync(cancellationToken);
    }

    private async Task ConsumeInMemoryRateLimitsAsync(
        IReadOnlyCollection<RateLimitSpec> limits,
        Guid tenantId,
        DateTimeOffset bucketStart,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        await InMemoryRateLimitLock.WaitAsync(cancellationToken);
        try
        {
            var counters = new List<(RateLimitSpec Limit, SmsSendRateLimit? Counter)>();
            foreach (var limit in limits)
            {
                var counter = await dbContext.SmsSendRateLimits.FindAsync(
                    [tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
                    cancellationToken);
                if (counter?.RequestCount >= limit.Maximum)
                {
                    throw new SmsRateLimitedException();
                }

                counters.Add((limit, counter));
            }

            foreach (var (limit, existingCounter) in counters)
            {
                var counter = existingCounter;
                if (counter is null)
                {
                    counter = new SmsSendRateLimit
                    {
                        TenantId = tenantId,
                        Dimension = limit.Dimension,
                        ScopeHash = limit.ScopeHash,
                        BucketStart = bucketStart
                    };
                    dbContext.SmsSendRateLimits.Add(counter);
                }

                counter.RequestCount++;
                counter.UpdatedAt = now;
            }

            await dbContext.SaveChangesAsync(cancellationToken);
        }
        finally
        {
            InMemoryRateLimitLock.Release();
        }
    }

    private IReadOnlyList<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
    {
        var limits = new List<RateLimitSpec>
        {
            CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
            CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour)
        };

        if (!string.IsNullOrWhiteSpace(request.IpAddress))
        {
            limits.Add(CreateLimit(
                SmsRateLimitDimension.Ip,
                $"ip:{request.IpAddress.Trim()}",
                options.IpRequestsPerHour));
        }

        var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId)
            ? request.UserAgent
            : request.DeviceId;
        if (!string.IsNullOrWhiteSpace(deviceKey))
        {
            limits.Add(CreateLimit(
                SmsRateLimitDimension.Device,
                $"device:{deviceKey.Trim()}",
                options.DeviceRequestsPerHour));
        }

        return limits;
    }

    private RateLimitSpec CreateLimit(SmsRateLimitDimension dimension, string scope, int maximum)
    {
        return new RateLimitSpec(
            dimension,
            SmsCodeHashing.HashScope(scope, options.CodePepper),
            maximum);
    }

    private async Task ExpirePreviousCodesAsync(
        Guid tenantId,
        string phone,
        SmsPurpose purpose,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        var query = dbContext.SmsVerificationCodes.Where(entity =>
            entity.TenantId == tenantId &&
            entity.Phone == phone &&
            entity.Purpose == purpose &&
            entity.ConsumedAt == null &&
            (entity.Status == SmsVerificationStatus.Pending ||
             entity.Status == SmsVerificationStatus.Sent));

        if (dbContext.Database.IsRelational())
        {
            await query.ExecuteUpdateAsync(
                setters => setters
                    .SetProperty(entity => entity.Status, SmsVerificationStatus.Expired)
                    .SetProperty(entity => entity.ExpiresAt, now),
                cancellationToken);
            return;
        }

        foreach (var verification in await query.ToListAsync(cancellationToken))
        {
            verification.Status = SmsVerificationStatus.Expired;
            verification.ExpiresAt = now;
        }
    }

    private async Task MarkExpiredAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
    {
        if (dbContext.Database.IsRelational())
        {
            await dbContext.SmsVerificationCodes
                .Where(entity =>
                    entity.Id == id &&
                    entity.Status == SmsVerificationStatus.Sent &&
                    entity.ConsumedAt == null &&
                    entity.ExpiresAt <= now)
                .ExecuteUpdateAsync(
                    setters => setters.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired),
                    cancellationToken);
            return;
        }

        var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
        if (verification is not null &&
            verification.Status == SmsVerificationStatus.Sent &&
            verification.ConsumedAt is null &&
            verification.ExpiresAt <= now)
        {
            verification.Status = SmsVerificationStatus.Expired;
            await dbContext.SaveChangesAsync(cancellationToken);
        }
    }

    private async Task<bool> TryConsumeAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
    {
        if (dbContext.Database.IsRelational())
        {
            var affected = await dbContext.SmsVerificationCodes
                .Where(entity =>
                    entity.Id == id &&
                    entity.Status == SmsVerificationStatus.Sent &&
                    entity.ConsumedAt == null &&
                    entity.ExpiresAt > now &&
                    entity.Attempts < options.MaxVerificationAttempts)
                .ExecuteUpdateAsync(
                    setters => setters
                        .SetProperty(entity => entity.Status, SmsVerificationStatus.Verified)
                        .SetProperty(entity => entity.ConsumedAt, now),
                    cancellationToken);
            return affected == 1;
        }

        var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
        if (verification is null ||
            verification.Status != SmsVerificationStatus.Sent ||
            verification.ConsumedAt is not null ||
            verification.ExpiresAt <= now ||
            verification.Attempts >= options.MaxVerificationAttempts)
        {
            return false;
        }

        verification.Status = SmsVerificationStatus.Verified;
        verification.ConsumedAt = now;
        await dbContext.SaveChangesAsync(cancellationToken);
        return true;
    }

    private async Task RecordFailedAttemptAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
    {
        if (dbContext.Database.IsRelational())
        {
            await dbContext.SmsVerificationCodes
                .Where(entity =>
                    entity.Id == id &&
                    entity.Status == SmsVerificationStatus.Sent &&
                    entity.ConsumedAt == null &&
                    entity.ExpiresAt > now &&
                    entity.Attempts < options.MaxVerificationAttempts)
                .ExecuteUpdateAsync(
                    setters => setters
                        .SetProperty(entity => entity.Attempts, entity => entity.Attempts + 1)
                        .SetProperty(
                            entity => entity.Status,
                            entity => entity.Attempts + 1 >= options.MaxVerificationAttempts
                                ? SmsVerificationStatus.Blocked
                                : SmsVerificationStatus.Sent),
                    cancellationToken);
            return;
        }

        var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
        if (verification is null ||
            verification.Status != SmsVerificationStatus.Sent ||
            verification.ConsumedAt is not null ||
            verification.ExpiresAt <= now ||
            verification.Attempts >= options.MaxVerificationAttempts)
        {
            return;
        }

        verification.Attempts++;
        if (verification.Attempts >= options.MaxVerificationAttempts)
        {
            verification.Status = SmsVerificationStatus.Blocked;
        }

        await dbContext.SaveChangesAsync(cancellationToken);
    }

    private void EnsureValidOptions()
    {
        if (!SmsSecurityOptions.BeValid(options))
        {
            throw new InvalidOperationException(
                $"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " +
                "exactly five verification attempts, and positive rate limits.");
        }
    }

    private static bool HashesMatch(string expected, string actual)
    {
        try
        {
            return CryptographicOperations.FixedTimeEquals(
                Convert.FromHexString(expected),
                Convert.FromHexString(actual));
        }
        catch (FormatException)
        {
            return false;
        }
    }

    private static string ToSnakeCase(SmsRateLimitDimension dimension)
    {
        return dimension.ToString().ToLowerInvariant();
    }

    private static DateTimeOffset TruncateToHour(DateTimeOffset value)
    {
        return new DateTimeOffset(
            value.Year,
            value.Month,
            value.Day,
            value.Hour,
            0,
            0,
            value.Offset);
    }

    private sealed record RateLimitSpec(
        SmsRateLimitDimension Dimension,
        string ScopeHash,
        int Maximum);
}
