using System.Security.Claims;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
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.Refresh;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;

namespace SIGCM2.Application.Tests.Auth.Refresh;

public class RefreshCommandHandlerTests
{
    private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
    private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
    private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
    private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
    private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
    private readonly ISecurityEventLogger _security = Substitute.For<ISecurityEventLogger>();
    private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
    private readonly RefreshCommandHandler _handler;

    private static readonly Usuario ActiveUsuario = new(
        id: 1, username: "admin", passwordHash: "$2a$12$hash",
        nombre: "Admin", apellido: "Sys", email: null,
        rol: "admin", permisosJson: "[\"*\"]", activo: true);

    public RefreshCommandHandlerTests()
    {
        _clientCtx.Ip.Returns("127.0.0.1");
        _clientCtx.UserAgent.Returns("TestAgent/1.0");
        _generator.Generate().Returns("new_raw_token_value_xyz");

        _handler = new RefreshCommandHandler(
            _refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions, _security,
            TimeProvider.System);
    }

    // Helper: build an active stored RefreshToken with a matching principal
    private (RefreshToken stored, string rawToken, ClaimsPrincipal principal) MakeActiveToken(
        int usuarioId = 1, bool expired = false, bool revoked = false)
    {
        const string rawToken = "test_raw_token_value_abc";
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        var now = DateTime.UtcNow;
        var expiresAt = expired ? now.AddDays(-1) : now.AddDays(6);
        var issuedAt = now.AddHours(-1);

        var stored = RefreshToken.IssueForNewFamily(usuarioId, hash, issuedAt, TimeSpan.FromDays(7), "10.0.0.1", null);
        // We need to set ExpiresAt to our custom value — since IssueForNewFamily uses now+ttl,
        // we build differently: use reflection trick. Instead, build a helper stored token directly.
        // The cleanest approach: build via the public factory, then adjust with MarkAsPersistedRevocation for revoked.

        // Build a fresh token that expires properly
        var storedToken = RefreshToken.IssueForNewFamily(
            usuarioId, hash,
            now: issuedAt,
            ttl: expired ? TimeSpan.FromSeconds(1) : TimeSpan.FromDays(7),
            createdByIp: "10.0.0.1",
            userAgent: null);

        if (revoked)
            storedToken.MarkAsPersistedRevocation(now.AddMinutes(-5), replacedById: null);

        var claims = new List<Claim>
        {
            new(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, usuarioId.ToString())
        };
        var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));

        return (storedToken, rawToken, principal);
    }

    // --- Happy path ---

    [Fact]
    public async Task Handle_HappyPath_RotatesAndReturnsNewPair()
    {
        var (stored, rawToken, principal) = MakeActiveToken();
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);
        _usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
        _jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access_token");
        _refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(99);

        var cmd = new RefreshCommand("old_access_token", rawToken);
        var result = await _handler.Handle(cmd);

        Assert.Equal("new_access_token", result.AccessToken);
        Assert.Equal("new_raw_token_value_xyz", result.RefreshToken);
        Assert.Equal(3600, result.ExpiresIn);

        await _refreshRepo.Received(1).AddAsync(Arg.Any<RefreshToken>());
        await _refreshRepo.Received(1).RevokeAsync(stored.Id, replacedById: 99, revokedAt: Arg.Any<DateTime>());
    }

    [Fact]
    public async Task Handle_NewTokenInheritsOriginalExpiresAt()
    {
        var (stored, rawToken, principal) = MakeActiveToken();
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);
        _usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
        _jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access");
        _refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(10);

        await _handler.Handle(new RefreshCommand("access", rawToken));

        // Verify the new token saved to repo inherits the ExpiresAt of the parent
        await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
            t.ExpiresAt == stored.ExpiresAt &&
            t.FamilyId == stored.FamilyId));
    }

    // --- Error paths ---

    [Fact]
    public async Task Handle_TokenHashNotFound_ThrowsInvalidRefreshToken()
    {
        var principal = new ClaimsPrincipal(new ClaimsIdentity(
            [new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "1")]));
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(Arg.Any<string>()).Returns((RefreshToken?)null);

        await Assert.ThrowsAsync<InvalidRefreshTokenException>(
            () => _handler.Handle(new RefreshCommand("access", "unknown_token")));
    }

    [Fact]
    public async Task Handle_TokenExpired_ThrowsInvalidRefreshToken()
    {
        var (stored, rawToken, principal) = MakeActiveToken(expired: true);
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);

        await Assert.ThrowsAsync<InvalidRefreshTokenException>(
            () => _handler.Handle(new RefreshCommand("access", rawToken)));
    }

    [Fact]
    public async Task Handle_TokenAlreadyRevoked_RevokesFamilyAndThrowsReuse()
    {
        var (stored, rawToken, principal) = MakeActiveToken(revoked: true);
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);

        await Assert.ThrowsAsync<TokenReuseDetectedException>(
            () => _handler.Handle(new RefreshCommand("access", rawToken)));

        await _refreshRepo.Received(1).RevokeFamilyAsync(stored.FamilyId, Arg.Any<DateTime>());
    }

    [Fact]
    public async Task Handle_AccessTokenSignatureInvalid_ThrowsInvalidRefreshToken()
    {
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>())
            .Throws(new Microsoft.IdentityModel.Tokens.SecurityTokenException("Bad sig"));

        await Assert.ThrowsAsync<InvalidRefreshTokenException>(
            () => _handler.Handle(new RefreshCommand("bad.access.token", "some_refresh")));
    }

    [Fact]
    public async Task Handle_AccessTokenSubMismatch_ThrowsInvalidRefreshToken()
    {
        // stored token is for user 1, but access token claims user 99
        var (stored, rawToken, _) = MakeActiveToken(usuarioId: 1);
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        var mismatchPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
            [new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "99")]));

        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(mismatchPrincipal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);

        await Assert.ThrowsAsync<InvalidRefreshTokenException>(
            () => _handler.Handle(new RefreshCommand("access_user99", rawToken)));
    }

    [Fact]
    public async Task Handle_UsuarioInactive_ThrowsInvalidRefreshToken()
    {
        var (stored, rawToken, principal) = MakeActiveToken();
        var hash = TokenHasher.Sha256Base64Url(rawToken);
        _jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
        _refreshRepo.GetByHashAsync(hash).Returns(stored);

        var inactiveUser = new Usuario(1, "admin", "$hash", "Admin", "Sys", null, "admin", "[\"*\"]", activo: false);
        _usuarioRepo.GetByIdAsync(1).Returns(inactiveUser);

        await Assert.ThrowsAsync<InvalidRefreshTokenException>(
            () => _handler.Handle(new RefreshCommand("access", rawToken)));
    }
}
