using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;

namespace Tiku.UnitTests.Auth;

public sealed class SmsVerificationServiceTests
{
    private const string Pepper = "unit-test-sms-code-pepper-32-characters";

    [Fact]
    public void Hash_uses_the_server_pepper()
    {
        var tenantId = Guid.NewGuid();

        var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper);
        var second = SmsCodeHashing.Hash(
            tenantId,
            "13800000000",
            SmsPurpose.Login,
            "123456",
            "another-unit-test-pepper-32-characters");

        Assert.NotEqual(first, second);
        Assert.Equal(64, first.Length);
    }

    [Fact]
    public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var provider = new CapturingSmsProvider();
        var service = CreateService(context, provider);

        await service.CreateCodeAsync(new SendSmsCodeRequest(
            tenantId,
            "13800000000",
            SmsPurpose.Login,
            "127.0.0.1",
            "test-device"));

        Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code);
        Assert.Equal(4, context.SmsSendRateLimits.Count());
        Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant);
        Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone);
        Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip);
        Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device);
        var sendEvent = Assert.Single(context.AuthLoginEvents);
        Assert.Equal(AuthLoginResult.Sent, sendEvent.Result);
        Assert.Equal("sms", sendEvent.Provider);
    }

    [Fact]
    public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var verification = await SeedCodeAsync(context, tenantId, "123456");
        var service = CreateService(context);

        for (var attempt = 0; attempt < 5; attempt++)
            await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
                service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999"));

        context.ChangeTracker.Clear();
        var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id);
        Assert.NotNull(blocked);
        Assert.Equal(5, blocked.Attempts);
        Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status);
        await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
            service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
    }

    [Fact]
    public async Task Expired_code_is_transitioned_to_expired()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var verification = await SeedCodeAsync(
            context,
            tenantId,
            "123456",
            DateTimeOffset.UtcNow.AddSeconds(-1));
        var service = CreateService(context);

        await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
            service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));

        context.ChangeTracker.Clear();
        Assert.Equal(
            SmsVerificationStatus.Expired,
            (await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status);
    }

    [Fact]
    public async Task Successful_code_can_only_be_consumed_once()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var verification = await SeedCodeAsync(context, tenantId, "123456");
        var service = CreateService(context);

        await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456");
        await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
            service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));

        context.ChangeTracker.Clear();
        var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id);
        Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status);
        Assert.NotNull(consumed.ConsumedAt);
    }

    [Fact]
    public async Task Phone_limit_applies_across_ip_and_device_changes()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var options = new SmsSecurityOptions
        {
            CodePepper = Pepper,
            TenantRequestsPerHour = 100,
            PhoneRequestsPerHour = 1,
            IpRequestsPerHour = 20,
            DeviceRequestsPerHour = 10
        };
        var service = CreateService(context, options: options);

        await service.CreateCodeAsync(new SendSmsCodeRequest(
            tenantId,
            "13800000000",
            SmsPurpose.Login,
            "127.0.0.1",
            "device-one"));

        await Assert.ThrowsAsync<SmsRateLimitedException>(() =>
            service.CreateCodeAsync(new SendSmsCodeRequest(
                tenantId,
                "13800000000",
                SmsPurpose.Login,
                "127.0.0.2",
                "device-two")));
    }

    [Fact]
    public async Task Successful_sms_consumes_quota_once_without_compensation()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var quota = new RecordingFeatureAccessService();
        var service = CreateService(context, featureAccessService: quota);

        await service.CreateCodeAsync(new SendSmsCodeRequest(
            tenantId,
            "13800000000",
            SmsPurpose.Login,
            "127.0.0.1",
            "quota-success"));

        Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
        Assert.Empty(quota.Releases);
    }

    [Fact]
    public async Task Failed_sms_send_releases_reserved_quota()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var quota = new RecordingFeatureAccessService();
        var service = CreateService(context, new FailingSmsProvider(), featureAccessService: quota);

        await Assert.ThrowsAsync<SmsProviderException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
            tenantId,
            "13800000000",
            SmsPurpose.Login,
            "127.0.0.1",
            "quota-failure")));

        Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
        Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Releases);
        Assert.Equal(SmsVerificationStatus.Failed, Assert.Single(context.SmsVerificationCodes).Status);
    }

    [Fact]
    public async Task Exhausted_sms_quota_rejects_before_provider_send()
    {
        await using var context = CreateContext();
        var tenantId = await SeedTenantAsync(context);
        var provider = new CapturingSmsProvider();
        var quota = new RecordingFeatureAccessService { AllowConsumption = false };
        var service = CreateService(context, provider, featureAccessService: quota);

        var exception = await Assert.ThrowsAsync<FeatureAccessException>(() =>
            service.CreateCodeAsync(new SendSmsCodeRequest(
                tenantId,
                "13800000000",
                SmsPurpose.Login,
                "127.0.0.1",
                "quota-exhausted")));

        Assert.Equal("feature_quota_exhausted", exception.Code);
        Assert.Empty(provider.Requests);
        Assert.Empty(quota.Releases);
    }

    private static SmsVerificationService CreateService(
        TikuDbContext context,
        ISmsProvider? provider = null,
        SmsSecurityOptions? options = null,
        IFeatureAccessService? featureAccessService = null)
    {
        return new SmsVerificationService(
            context,
            provider ?? new CapturingSmsProvider(),
            featureAccessService ?? new RecordingFeatureAccessService(),
            Options.Create(options ?? ValidOptions()));
    }

    private static SmsSecurityOptions ValidOptions()
    {
        return new SmsSecurityOptions
        {
            CodePepper = Pepper,
            TenantRequestsPerHour = 100,
            PhoneRequestsPerHour = 5,
            IpRequestsPerHour = 20,
            DeviceRequestsPerHour = 10
        };
    }

    private static TikuDbContext CreateContext()
    {
        return new TikuDbContext(
            new DbContextOptionsBuilder<TikuDbContext>()
                .UseInMemoryDatabase(Guid.NewGuid().ToString())
                .Options);
    }

    private static async Task<Guid> SeedTenantAsync(TikuDbContext context)
    {
        var tenant = new Tenant
        {
            Id = Guid.NewGuid(),
            Slug = Guid.NewGuid().ToString("N"),
            Name = "SMS Test Tenant"
        };
        context.Tenants.Add(tenant);
        await context.SaveChangesAsync();
        return tenant.Id;
    }

    private static async Task<SmsVerificationCode> SeedCodeAsync(
        TikuDbContext context,
        Guid tenantId,
        string code,
        DateTimeOffset? expiresAt = null)
    {
        var verification = new SmsVerificationCode
        {
            TenantId = tenantId,
            Phone = "13800000000",
            Purpose = SmsPurpose.Login,
            CodeHash = SmsCodeHashing.Hash(
                tenantId,
                "13800000000",
                SmsPurpose.Login,
                code,
                Pepper),
            Status = SmsVerificationStatus.Sent,
            ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5)
        };
        context.SmsVerificationCodes.Add(verification);
        await context.SaveChangesAsync();
        return verification;
    }

    private sealed class CapturingSmsProvider : ISmsProvider
    {
        public List<SmsProviderSendRequest> Requests { get; } = [];

        public Task<SmsProviderSendResult> SendAsync(
            SmsProviderSendRequest request,
            CancellationToken cancellationToken = default)
        {
            Requests.Add(request);
            return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
        }
    }

    private sealed class FailingSmsProvider : ISmsProvider
    {
        public Task<SmsProviderSendResult> SendAsync(
            SmsProviderSendRequest request,
            CancellationToken cancellationToken = default)
        {
            throw new SmsProviderException("provider failed", "sms_provider_send_failed");
        }
    }

    private sealed class RecordingFeatureAccessService : IFeatureAccessService
    {
        public bool AllowConsumption { get; init; } = true;
        public List<(Guid TenantId, string MetricCode, long Amount)> Consumptions { get; } = [];
        public List<(Guid TenantId, string MetricCode, long Amount)> Releases { get; } = [];

        public Task<bool> TryConsumeQuotaAsync(
            Guid tenantId,
            string metricCode,
            long amount,
            CancellationToken cancellationToken = default)
        {
            Consumptions.Add((tenantId, metricCode, amount));
            return Task.FromResult(AllowConsumption);
        }

        public Task ReleaseQuotaAsync(
            Guid tenantId,
            string metricCode,
            long amount,
            CancellationToken cancellationToken = default)
        {
            Releases.Add((tenantId, metricCode, amount));
            return Task.CompletedTask;
        }

        public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode,
            FeatureAccessOperation operation, CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }

        public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId,
            FeatureAccessOperation operation = FeatureAccessOperation.Read,
            CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }

        public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes,
            FeatureAccessOperation operation = FeatureAccessOperation.Read,
            CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }

        public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId,
            CancellationToken cancellationToken = default)
        {
            throw new NotSupportedException();
        }
    }
}