using Microsoft.Extensions.Logging;
using NSubstitute;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;

namespace SIGCM2.Application.Tests.Auth.Login;

public class LoginCommandHandlerTests
{
    private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
    private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
    private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
    private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
    private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
    private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
    private readonly IRolPermisoRepository _rolPermisoRepo = Substitute.For<IRolPermisoRepository>();
    private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
    private readonly ILogger<LoginCommandHandler> _logger = Substitute.For<ILogger<LoginCommandHandler>>();
    private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
    private readonly LoginCommandHandler _handler;

    public LoginCommandHandlerTests()
    {
        _clientCtx.Ip.Returns("127.0.0.1");
        _clientCtx.UserAgent.Returns("TestAgent");
        _refreshGenerator.Generate().Returns("raw_refresh_token_value");
        _refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1);

        // Default: UpdateUltimoLoginAsync succeeds silently
        _repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);

        // Default: repo devuelve lista vacía — tests que necesitan permisos la sobreescriben
        _rolPermisoRepo.GetByRolCodigoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(new List<Permiso>().AsReadOnly());

        _handler = new LoginCommandHandler(
            _repository, _hasher, _jwtService,
            _refreshRepo, _refreshGenerator, _clientCtx, _authOptions,
            _rolPermisoRepo, _security, _logger, TimeProvider.System);
    }

    // Scenario: valid credentials → returns token response with usuario populated
    [Fact]
    public async Task Handle_ValidCredentials_ReturnsTokenResponse()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here");

        var command = new LoginCommand("admin", "@Diego550@");
        var result = await _handler.Handle(command);

        Assert.Equal("jwt.token.here", result.AccessToken);
        Assert.False(string.IsNullOrWhiteSpace(result.RefreshToken));
        Assert.Equal(3600, result.ExpiresIn);

        // Contract: Usuario must be populated
        Assert.NotNull(result.Usuario);
        Assert.Equal(1, result.Usuario.Id);
        Assert.Equal("Admin Sys", result.Usuario.Nombre);
        Assert.Equal("admin", result.Usuario.Rol);
        Assert.NotNull(result.Usuario.Permisos);
    }

    // UDT-006 B-05: Permisos vienen desde IRolPermisoRepository, no desde PermisosJson
    [Fact]
    public async Task Handle_AdminLogin_PermisosFromRolPermisoRepository()
    {
        // Arrange
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt.token.here");

        var adminPermisos = new List<Permiso>
        {
            MakePermiso(1, "administracion:usuarios:gestionar"),
            MakePermiso(2, "administracion:roles:gestionar"),
            MakePermiso(3, "administracion:permisos:ver"),
        };
        _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
            .Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        // Act
        var result = await _handler.Handle(new LoginCommand("admin", "@Diego550@"));

        // Assert — permisos vienen del repo, no de PermisosJson
        Assert.Equal(3, result.Usuario.Permisos.Length);
        Assert.Contains("administracion:usuarios:gestionar", result.Usuario.Permisos);
        Assert.Contains("administracion:roles:gestionar", result.Usuario.Permisos);
        Assert.Contains("administracion:permisos:ver", result.Usuario.Permisos);
        // IRolPermisoRepository fue consultado con el rol del usuario
        await _rolPermisoRepo.Received(1).GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>());
    }

    // UDT-006 B-05-03: Rol sin permisos en RolPermiso → array vacío (nunca null)
    [Fact]
    public async Task Handle_RolSinPermisos_PermisosArrayVacio()
    {
        // Arrange
        var usuario = new Usuario(5, "rep1", "$2a$12$hash", "Rep", "User", null, "reportes", "[]", true);
        _repository.GetByUsernameAsync("rep1").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt.rep");

        // repo devuelve lista vacía para "reportes"
        _rolPermisoRepo.GetByRolCodigoAsync("reportes", Arg.Any<CancellationToken>())
            .Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);

        // Act
        var result = await _handler.Handle(new LoginCommand("rep1", "pass"));

        // Assert — empty array, NOT null
        Assert.NotNull(result.Usuario.Permisos);
        Assert.Empty(result.Usuario.Permisos);
    }

    // Triangulation: Usuario object maps id/nombre/rol/permisos from authenticated user
    [Fact]
    public async Task Handle_ValidCredentials_UsuarioMatchesAuthenticatedUser()
    {
        var cajeroPermisos = new List<Permiso>
        {
            MakePermiso(10, "ventas:contado:crear"),
            MakePermiso(11, "ventas:contado:cobrar"),
        };
        var usuario = new Usuario(42, "cajero1", "$2a$12$hash3", "María", "González", null, "cajero",
            "[]", true);
        _repository.GetByUsernameAsync("cajero1").Returns(usuario);
        _hasher.Verify("pass123", "$2a$12$hash3").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt.cajero.token");
        _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
            .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        var command = new LoginCommand("cajero1", "pass123");
        var result = await _handler.Handle(command);

        Assert.Equal(42, result.Usuario.Id);
        Assert.Equal("María González", result.Usuario.Nombre);
        Assert.Equal("cajero", result.Usuario.Rol);
        Assert.Equal(2, result.Usuario.Permisos.Length);
        Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
        Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
    }

    // ── UDT-009: PermisoResolver integration in LoginCommandHandler ─────────────

    // L-01: Admin sin overrides → permisos = exactamente los del rol
    [Fact]
    public async Task Handle_AdminNoOverrides_PermisosEqualRolPermisos()
    {
        // Arrange
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin",
            """{"grant":[],"deny":[]}""", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");

        var adminPermisos = Enumerable.Range(1, 21)
            .Select(i => MakePermiso(i, $"perm:mod{i}:accion{i}"))
            .ToList();
        _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
            .Returns(adminPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        // Act
        var result = await _handler.Handle(new LoginCommand("admin", "pass"));

        // Assert
        Assert.Equal(21, result.Usuario.Permisos.Length);
    }

    // L-02: Cajero + grant nuevo permiso → result contiene permiso del grant
    [Fact]
    public async Task Handle_CajeroWithGrant_PermisosContainGrantedPermiso()
    {
        // Arrange
        var usuario = new Usuario(2, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
            """{"grant":["textos:editar"],"deny":[]}""", true);
        _repository.GetByUsernameAsync("cajero1").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");

        var cajeroPermisos = new List<Permiso>
        {
            MakePermiso(10, "ventas:contado:crear"),
            MakePermiso(11, "ventas:contado:modificar"),
            MakePermiso(12, "ventas:contado:cobrar"),
            MakePermiso(13, "ventas:contado:facturar"),
        };
        _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
            .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        // Act
        var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));

        // Assert
        Assert.Equal(5, result.Usuario.Permisos.Length);
        Assert.Contains("textos:editar", result.Usuario.Permisos);
        Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
    }

    // L-03: Cajero + deny uno del rol → result NO contiene el permiso denegado
    [Fact]
    public async Task Handle_CajeroWithDeny_PermisosExcludeDeniedPermiso()
    {
        // Arrange
        var usuario = new Usuario(3, "cajero2", "$2a$12$hash", "C", "B", null, "cajero",
            """{"grant":[],"deny":["ventas:contado:cobrar"]}""", true);
        _repository.GetByUsernameAsync("cajero2").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");

        var cajeroPermisos = new List<Permiso>
        {
            MakePermiso(10, "ventas:contado:crear"),
            MakePermiso(11, "ventas:contado:modificar"),
            MakePermiso(12, "ventas:contado:cobrar"),
            MakePermiso(13, "ventas:contado:facturar"),
        };
        _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
            .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        // Act
        var result = await _handler.Handle(new LoginCommand("cajero2", "pass"));

        // Assert — ventas:contado:cobrar was denied
        Assert.Equal(3, result.Usuario.Permisos.Length);
        Assert.DoesNotContain("ventas:contado:cobrar", result.Usuario.Permisos);
        Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
    }

    // L-04: DTO always returns Permisos as string[] — not grant/deny shape
    [Fact]
    public async Task Handle_AlwaysReturnsPermisosAsStringArray_NotGrantDenyShape()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "A", "B", null, "admin",
            """{"grant":["extra:perm"],"deny":[]}""", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");
        _rolPermisoRepo.GetByRolCodigoAsync("admin", Arg.Any<CancellationToken>())
            .Returns(new List<Permiso>().AsReadOnly() as IReadOnlyList<Permiso>);

        var result = await _handler.Handle(new LoginCommand("admin", "pass"));

        // Must be string[] — no grant/deny wrapping
        Assert.IsType<string[]>(result.Usuario.Permisos);
    }

    // L-05: Legacy PermisosJson "[]" → treated as Empty → permisos = only rol
    [Fact]
    public async Task Handle_LegacyPermisosJson_EmptyArray_TreatedAsEmpty()
    {
        var usuario = new Usuario(1, "cajero1", "$2a$12$hash", "C", "A", null, "cajero",
            "[]", true);
        _repository.GetByUsernameAsync("cajero1").Returns(usuario);
        _hasher.Verify("pass", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");

        var cajeroPermisos = new List<Permiso>
        {
            MakePermiso(10, "ventas:contado:crear"),
            MakePermiso(11, "ventas:contado:cobrar"),
        };
        _rolPermisoRepo.GetByRolCodigoAsync("cajero", Arg.Any<CancellationToken>())
            .Returns(cajeroPermisos.AsReadOnly() as IReadOnlyList<Permiso>);

        var result = await _handler.Handle(new LoginCommand("cajero1", "pass"));

        Assert.Equal(2, result.Usuario.Permisos.Length);
        Assert.Contains("ventas:contado:crear", result.Usuario.Permisos);
        Assert.Contains("ventas:contado:cobrar", result.Usuario.Permisos);
    }

    // Helper: construir Permiso via ForRead para tests
    private static Permiso MakePermiso(int id, string codigo) =>
        Permiso.ForRead(id, codigo, codigo, null, codigo.Split(':')[0], true, DateTime.UtcNow);

    // Scenario: user does not exist → throws InvalidCredentialsException
    [Fact]
    public async Task Handle_UserNotFound_ThrowsInvalidCredentialsException()
    {
        _repository.GetByUsernameAsync("noexiste").Returns((Usuario?)null);

        var command = new LoginCommand("noexiste", "anything");

        await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
    }

    // Scenario: user is inactive → throws InvalidCredentialsException
    [Fact]
    public async Task Handle_InactiveUser_ThrowsInvalidCredentialsException()
    {
        var inactive = new Usuario(2, "operador", "$2a$12$hash2", "Juan", "Pérez", null, "vendedor", "[]", false);
        _repository.GetByUsernameAsync("operador").Returns(inactive);

        var command = new LoginCommand("operador", "correctpassword");

        await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
    }

    // Scenario: wrong password → throws InvalidCredentialsException
    [Fact]
    public async Task Handle_WrongPassword_ThrowsInvalidCredentialsException()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("WrongPass1", "$2a$12$hash").Returns(false);

        var command = new LoginCommand("admin", "WrongPass1");

        await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
    }

    // T-034: Refresh token persistence on login

    [Fact]
    public async Task Handle_PersistsHashedRefreshToken()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
        _jwtService.GenerateAccessToken(usuario).Returns("jwt");

        var command = new LoginCommand("admin", "@Diego550@");
        var result = await _handler.Handle(command);

        // Raw token returned to client
        Assert.Equal("raw_refresh_token_value", result.RefreshToken);

        // Repository received a token with the hash of the raw value — not the raw itself
        var expectedHash = TokenHasher.Sha256Base64Url("raw_refresh_token_value");
        await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
            t.TokenHash == expectedHash &&
            t.UsuarioId == 1));
    }

    [Fact]
    public async Task Handle_GeneratesNewFamilyId()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        var capturedFamilies = new List<Guid>();
        _refreshRepo.AddAsync(Arg.Do<RefreshToken>(t => capturedFamilies.Add(t.FamilyId))).Returns(1);

        await _handler.Handle(new LoginCommand("admin", "@Diego550@"));
        await _handler.Handle(new LoginCommand("admin", "@Diego550@"));

        // Each login gets a unique FamilyId
        Assert.Equal(2, capturedFamilies.Count);
        Assert.NotEqual(capturedFamilies[0], capturedFamilies[1]);
    }

    [Fact]
    public async Task Handle_UsesConfiguredRefreshTokenDays()
    {
        var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
        _repository.GetByUsernameAsync("admin").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        var before = DateTime.UtcNow;
        await _handler.Handle(new LoginCommand("admin", "@Diego550@"));
        var after = DateTime.UtcNow;

        // The token added to the repo must have ExpiresAt ~7 days from now
        await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
            t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
            t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
    }

    // ── UDT-008: username + mustChangePassword + UltimoLogin ─────────────────

    [Fact]
    public async Task Handle_PopulatesUsername_InUsuarioDto()
    {
        var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
        _repository.GetByUsernameAsync("jperez").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        var result = await _handler.Handle(new LoginCommand("jperez", "pass"));

        Assert.Equal("jperez", result.Usuario.Username);
    }

    [Fact]
    public async Task Handle_PopulatesMustChangePassword_False_WhenZero()
    {
        var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true,
            mustChangePassword: false);
        _repository.GetByUsernameAsync("jperez").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        var result = await _handler.Handle(new LoginCommand("jperez", "pass"));

        Assert.False(result.Usuario.MustChangePassword);
    }

    [Fact]
    public async Task Handle_PopulatesMustChangePassword_True_WhenSet()
    {
        var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true,
            mustChangePassword: true);
        _repository.GetByUsernameAsync("jperez").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        var result = await _handler.Handle(new LoginCommand("jperez", "pass"));

        Assert.True(result.Usuario.MustChangePassword);
    }

    [Fact]
    public async Task Handle_CallsUpdateUltimoLoginAsync_AfterSuccessfulAuth()
    {
        var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
        _repository.GetByUsernameAsync("jperez").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        await _handler.Handle(new LoginCommand("jperez", "pass"));

        await _repository.Received(1).UpdateUltimoLoginAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_Succeeds_EvenIf_UpdateUltimoLogin_Throws()
    {
        var usuario = new Usuario(1, "jperez", "$2a$12$hash", "Juan", "Pérez", null, "cajero", "[]", true);
        _repository.GetByUsernameAsync("jperez").Returns(usuario);
        _hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
        _jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");

        // Simulate DB hiccup on UltimoLogin update
        _repository.UpdateUltimoLoginAsync(Arg.Any<int>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException(new Exception("DB timeout")));

        // Login must still succeed
        var result = await _handler.Handle(new LoginCommand("jperez", "pass"));
        Assert.NotNull(result);
        Assert.NotNull(result.AccessToken);
    }
}
