using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;

namespace Tiku.UnitTests.Auth;

public sealed class AuthServiceTests
{
    [Fact]
    public void Identity_password_hasher_uses_current_identity_format()
    {
        var user = new User();
        var hasher = new PasswordHasher<User>(Options.Create(new PasswordHasherOptions
        {
            IterationCount = 210_000
        }));
        var hash = hasher.HashPassword(user, "passw0rd!");

        Assert.Equal(PasswordVerificationResult.Success, hasher.VerifyHashedPassword(user, hash, "passw0rd!"));
        Assert.Equal(PasswordVerificationResult.Failed, hasher.VerifyHashedPassword(user, hash, "wrong"));
    }

    [Theory]
    [InlineData("abc12345", true)]
    [InlineData("ABC12345", true)]
    [InlineData("abcdefgh", false)]
    [InlineData("12345678", false)]
    [InlineData("abc1234", false)]
    public async Task Default_password_policy_requires_eight_characters_letters_and_digits(
        string password,
        bool expectedSuccess)
    {
        var validator = new LetterAndDigitPasswordValidator<User>();

        var result = await validator.ValidateAsync(null!, new User(), password);

        Assert.Equal(expectedSuccess, result.Succeeded);
    }

    [Fact]
    public async Task Password_login_issues_tenant_session_for_regular_member()
    {
        await using var fixture = await AuthFixture.CreateAsync();

        var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
            AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, "127.0.0.1", "unit-test"));

        Assert.Equal(AuthenticationStatus.Authenticated, result.Status);
        Assert.Equal(fixture.UserId, result.User!.UserId);
        Assert.Equal(AuthRealm.Tenant, result.User.Realm);
        Assert.False(string.IsNullOrWhiteSpace(result.User.Tokens.AccessToken));
        Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
    }

    [Fact]
    public async Task Five_wrong_passwords_lock_the_identity_account()
    {
        await using var fixture = await AuthFixture.CreateAsync();

        for (var attempt = 0; attempt < 5; attempt++)
        {
            await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
                fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
                    AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, "wrong-password", null, null)));
        }

        var user = await fixture.UserManager.FindByIdAsync(fixture.UserId.ToString());
        Assert.True(await fixture.UserManager.IsLockedOutAsync(user!));
        Assert.NotNull(user!.LockoutEnd);
        var events = await fixture.DbContext.AuthLoginEvents
            .OrderBy(item => item.CreatedAt)
            .ToArrayAsync();
        Assert.Equal(5, events.Length);
        Assert.Equal(AuthLoginResult.Blocked, events[^1].Result);
        Assert.Equal("account_locked", events[^1].FailureCode);
    }

    [Fact]
    public async Task Backend_permission_user_authenticates_without_an_additional_challenge()
    {
        await using var fixture = await AuthFixture.CreateAsync(includeBackendPermission: true);

        var result = await fixture.AuthService.LoginWithPasswordAsync(new PasswordLoginRequest(
            AuthRealm.Tenant, fixture.TenantId, AuthFixture.Phone, AuthFixture.Password, null, null));

        Assert.Equal(AuthenticationStatus.Authenticated, result.Status);
        Assert.NotNull(result.User);
        Assert.Single(await fixture.DbContext.AuthSessions.ToArrayAsync());
        Assert.Empty(await fixture.DbContext.AuthChallenges.ToArrayAsync());
    }

    private sealed class AuthFixture : IAsyncDisposable
    {
        public const string Password = "passw0rd!123";
        public const string Phone = "13800000000";
        private readonly ServiceProvider provider;
        private readonly AsyncServiceScope scope;

        private AuthFixture(ServiceProvider provider, AsyncServiceScope scope)
        {
            this.provider = provider;
            this.scope = scope;
            DbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
            UserManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
            AuthService = scope.ServiceProvider.GetRequiredService<IAuthService>();
        }

        public TikuDbContext DbContext { get; }
        public UserManager<User> UserManager { get; }
        public IAuthService AuthService { get; }
        public Guid TenantId { get; private set; }
        public Guid UserId { get; private set; }

        public static async Task<AuthFixture> CreateAsync(bool includeBackendPermission = false)
        {
            var services = new ServiceCollection();
            services.AddLogging();
            services.AddDataProtection();
            services.AddAuthentication();
            services.AddDbContext<TikuDbContext>(options =>
                options.UseInMemoryDatabase(Guid.NewGuid().ToString("N")));
            services.AddIdentityCore<User>(options =>
                {
                    options.Password.RequiredLength = 8;
                    options.Password.RequireDigit = false;
                    options.Password.RequireLowercase = false;
                    options.Password.RequireUppercase = false;
                    options.Password.RequireNonAlphanumeric = false;
                    options.Lockout.MaxFailedAccessAttempts = 5;
                    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
                })
                .AddEntityFrameworkStores<TikuDbContext>()
                .AddSignInManager()
                .AddDefaultTokenProviders()
                .AddPasswordValidator<LetterAndDigitPasswordValidator<User>>();
            services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
            services.Configure<JwtOptions>(options =>
            {
                options.Issuer = "tiku-unit-tests";
                options.Audience = "tiku-unit-tests";
                options.KeyId = "unit-test-rsa";
            });
            services.AddSingleton<IJwtKeyRing, TestJwtKeyRing>();
            services.AddScoped<ITokenService, TokenService>();
            services.AddScoped<AuthSessionStore>();
            services.AddScoped<IAuthSessionStore>(sp => sp.GetRequiredService<AuthSessionStore>());
            services.AddScoped<ISmsVerificationService, RejectingSmsVerificationService>();
            services.AddScoped<IWechatOAuthClient, RejectingWechatClient>();
            services.AddScoped<ITenantExternalProviderConfigService, RejectingProviderConfigService>();
            services.AddScoped<IFeatureAccessService, UnlimitedFeatureAccessService>();
            services.AddScoped<IAuthService, AuthService>();

            var provider = services.BuildServiceProvider();
            var scope = provider.CreateAsyncScope();
            var fixture = new AuthFixture(provider, scope);
            await fixture.SeedAsync(includeBackendPermission);
            return fixture;
        }

        private sealed class UnlimitedFeatureAccessService : IFeatureAccessService
        {
            public Task<FeatureAccessDecision> EvaluateAsync(
                Guid tenantId,
                string featureCode,
                FeatureAccessOperation operation,
                CancellationToken cancellationToken = default) =>
                Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation));

            public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
                Guid tenantId,
                FeatureAccessOperation operation = FeatureAccessOperation.Read,
                CancellationToken cancellationToken = default) =>
                Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(SaasFeatureCatalog.All, StringComparer.Ordinal));

            public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
                Guid tenantId,
                IEnumerable<string> permissionCodes,
                FeatureAccessOperation operation = FeatureAccessOperation.Read,
                CancellationToken cancellationToken = default) =>
                Task.FromResult<IReadOnlySet<string>>(permissionCodes.ToHashSet(StringComparer.Ordinal));

            public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
                Guid tenantId,
                CancellationToken cancellationToken = default) =>
                Task.FromResult<IReadOnlyCollection<FeatureQuotaSnapshot>>([]);

            public Task<bool> TryConsumeQuotaAsync(
                Guid tenantId,
                string metricCode,
                long amount,
                CancellationToken cancellationToken = default) => Task.FromResult(true);

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

        private async Task SeedAsync(bool includeBackendPermission)
        {
            var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test" };
            var user = new User { UserName = Phone, Phone = Phone, PhoneNumber = Phone, Name = "Test User" };
            Assert.True((await UserManager.CreateAsync(user, Password)).Succeeded);
            TenantId = tenant.Id;
            UserId = user.Id;
            DbContext.Tenants.Add(tenant);
            DbContext.TenantMemberships.Add(new TenantMembership
            {
                TenantId = tenant.Id,
                UserId = user.Id,
                Role = TenantRole.Student,
                Status = MembershipStatus.Active
            });

            if (includeBackendPermission)
            {
                var role = new TenantBackendRole
                {
                    TenantId = tenant.Id,
                    Code = "teacher",
                    Name = "Teacher",
                    Status = BackendRoleStatus.Active
                };
                DbContext.TenantBackendRoles.Add(role);
                DbContext.BackendPermissions.Add(new BackendPermission
                {
                    Code = BackendPermissions.TenantDashboardView,
                    Name = "Dashboard",
                    Area = BackendPermissionArea.Tenant,
                    PermissionModuleCode = "tenant_dashboard"
                });
                DbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
                {
                    TenantId = tenant.Id,
                    UserId = user.Id,
                    RoleId = role.Id
                });
                DbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
                {
                    TenantId = tenant.Id,
                    RoleId = role.Id,
                    PermissionCode = BackendPermissions.TenantDashboardView
                });
            }

            await DbContext.SaveChangesAsync();
        }

        public async ValueTask DisposeAsync()
        {
            await scope.DisposeAsync();
            await provider.DisposeAsync();
        }
    }

    private sealed class TestJwtKeyRing : IJwtKeyRing, IDisposable
    {
        private readonly RSA rsa = RSA.Create(2048);
        public TestJwtKeyRing()
        {
            var key = new RsaSecurityKey(rsa) { KeyId = "unit-test-rsa" };
            SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
            ValidationKeys = [key];
        }

        public SigningCredentials SigningCredentials { get; }
        public IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
        public void Dispose() => rsa.Dispose();
    }

    private sealed class RejectingSmsVerificationService : ISmsVerificationService
    {
        public Task<SmsSendResult> CreateCodeAsync(SendSmsCodeRequest request, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
        public Task VerifyCodeAsync(Guid tenantId, string phone, SmsPurpose purpose, string code, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
    }

    private sealed class RejectingWechatClient : IWechatOAuthClient
    {
        public Task<WechatIdentity> ExchangeWebCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
        public Task<WechatIdentity> ExchangeMiniAppCodeAsync(WechatProviderOptions options, string code, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
    }

    private sealed class RejectingProviderConfigService : ITenantExternalProviderConfigService
    {
        public Task<TenantExternalProviderAccount> GetActiveProviderAsync(Guid tenantId, TenantExternalProviderCapability capability, string? provider = null, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
        public Task<IReadOnlyCollection<TenantExternalProviderItem>> GetProvidersAsync(Guid tenantId, TenantExternalProviderCapability? capability = null, string? provider = null, int? limit = null, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
        public Task<TenantExternalProviderItem> UpsertProviderAsync(Guid tenantId, UpsertTenantExternalProviderCommand command, CancellationToken cancellationToken = default) =>
            throw new NotSupportedException();
    }
}
