using Meezi.API.Models.Auth;
using Meezi.API.Security;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;

namespace Meezi.API.Services;

public class AuthService : IAuthService
{
    private const int OtpTtlSeconds = 300;
    private const int DefaultMaxOtpAttemptsPerHour = 5;

    private readonly AppDbContext _db;
    private readonly IConnectionMultiplexer _redis;
    private readonly ISmsService _smsService;
    private readonly IJwtTokenService _jwtTokenService;
    private readonly IRefreshTokenStore _refreshTokenStore;
    private readonly IConfiguration _configuration;
    private readonly ILogger<AuthService> _logger;
    private readonly IAbuseProtectionService _abuse;
    private readonly IHttpContextAccessor _http;

    public AuthService(
        AppDbContext db,
        IConnectionMultiplexer redis,
        ISmsService smsService,
        IJwtTokenService jwtTokenService,
        IRefreshTokenStore refreshTokenStore,
        IConfiguration configuration,
        IAbuseProtectionService abuse,
        IHttpContextAccessor http,
        ILogger<AuthService> logger)
    {
        _db = db;
        _redis = redis;
        _smsService = smsService;
        _jwtTokenService = jwtTokenService;
        _refreshTokenStore = refreshTokenStore;
        _configuration = configuration;
        _abuse = abuse;
        _http = http;
        _logger = logger;
    }

    public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> SendOtpAsync(
        SendOtpRequest request,
        CancellationToken cancellationToken = default)
    {
        var phone = PhoneNormalizer.Normalize(request.Phone);
        var redis = _redis.GetDatabase();
        var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);

        if (_http.HttpContext is not null)
        {
            var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
            var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
            if (!ipCheck.Allowed)
                return (false, null, ipCheck.ErrorCode, ipCheck.Message);
        }

        var employeeExists = await _db.Employees
            .AnyAsync(e => e.Phone == phone && e.DeletedAt == null, cancellationToken);

        if (!employeeExists)
            return (false, null, "NOT_FOUND", "No account found for this phone number.");

        var attemptsKey = $"otp:attempts:{phone}";
        if (maxAttempts > 0)
        {
            var attempts = await redis.StringGetAsync(attemptsKey);
            if (attempts.HasValue && (int)attempts >= maxAttempts)
                return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
        }

        var otp = Random.Shared.Next(100000, 999999).ToString();
        await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));

        try
        {
            await _smsService.SendOtpAsync(phone, otp, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send OTP SMS");
            return (false, null, "SMS_FAILED", "Could not send verification code.");
        }

        if (maxAttempts > 0)
        {
            var newAttempts = await redis.StringIncrementAsync(attemptsKey);
            if (newAttempts == 1)
                await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
        }

        _logger.LogInformation("OTP sent for phone ending {Suffix}", phone[^4..]);

        return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> VerifyOtpAsync(
        VerifyOtpRequest request,
        CancellationToken cancellationToken = default)
    {
        var phone = PhoneNormalizer.Normalize(request.Phone);
        var code = OtpNormalizer.Normalize(request.Code);
        if (!OtpNormalizer.IsValidSixDigitCode(code))
            return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);

        var redis = _redis.GetDatabase();

        var storedOtp = await redis.StringGetAsync($"otp:{phone}");
        if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
            return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);

        var query = _db.Employees
            .Include(e => e.Cafe)
            .Where(e => e.Phone == phone && e.DeletedAt == null);

        if (!string.IsNullOrWhiteSpace(request.CafeId))
            query = query.Where(e => e.CafeId == request.CafeId);

        var matches = await query.ToListAsync(cancellationToken);
        if (matches.Count == 0)
            return (false, null, "NOT_FOUND", "No account found for this phone number.", null);

        // Multiple cafés — ask frontend to pick one (OTP kept alive for the 2nd call)
        if (matches.Count > 1)
        {
            var choices = new CafeChoicesResponse(
                matches
                    .Where(e => e.Cafe is not null)
                    .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
                    .ToList());
            return (false, null, "CHOOSE_CAFE", null, choices);
        }

        var employee = matches[0];
        if (employee.Cafe is null)
            return (false, null, "NOT_FOUND", "No account found for this phone number.", null);

        await redis.KeyDeleteAsync($"otp:{phone}");

        // Fetch all memberships for this phone to include in the token response
        var allMemberships = await _db.Employees
            .Include(e => e.Cafe)
            .Where(e => e.Phone == phone && e.DeletedAt == null)
            .ToListAsync(cancellationToken);

        var membershipDtos = allMemberships
            .Where(e => e.Cafe is not null)
            .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
            .ToList();

        var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
        return (true, tokens, null, null, null);
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
        string employeeId, string targetCafeId,
        CancellationToken cancellationToken = default)
    {
        // Find the current employee to get their phone
        var currentEmployee = await _db.Employees
            .FirstOrDefaultAsync(e => e.Id == employeeId && e.DeletedAt == null, cancellationToken);
        if (currentEmployee is null)
            return (false, null, "NOT_FOUND", "User not found.");

        // Find their membership in the target café
        var targetEmployee = await _db.Employees
            .Include(e => e.Cafe)
            .FirstOrDefaultAsync(e => e.Phone == currentEmployee.Phone && e.CafeId == targetCafeId && e.DeletedAt == null, cancellationToken);
        if (targetEmployee?.Cafe is null)
            return (false, null, "NOT_FOUND", "You don't have access to this café.");

        var allMemberships = await _db.Employees
            .Include(e => e.Cafe)
            .Where(e => e.Phone == currentEmployee.Phone && e.DeletedAt == null)
            .ToListAsync(cancellationToken);

        var membershipDtos = allMemberships
            .Where(e => e.Cafe is not null)
            .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
            .ToList();

        var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken);
        return (true, tokens, null, null);
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
        RefreshTokenRequest request,
        CancellationToken cancellationToken = default)
    {
        var payload = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
        if (payload is null || payload.Actor == Meezi.Core.Constants.MeeziActorKinds.SystemAdmin)
            return (false, null, "INVALID_TOKEN", "Refresh token is invalid or expired.");

        var employee = await _db.Employees
            .Include(e => e.Cafe)
            .FirstOrDefaultAsync(e => e.Id == payload.UserId && e.CafeId == payload.CafeId && e.DeletedAt == null, cancellationToken);

        if (employee?.Cafe is null)
            return (false, null, "NOT_FOUND", "User no longer exists.");

        await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);

        var allMemberships = await _db.Employees
            .Include(e => e.Cafe)
            .Where(e => e.Phone == employee.Phone && e.DeletedAt == null)
            .ToListAsync(cancellationToken);

        var membershipDtos = allMemberships
            .Where(e => e.Cafe is not null)
            .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
            .ToList();

        var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
        return (true, tokens, null, null);
    }

    public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync(
        RegisterRequest request,
        CancellationToken cancellationToken = default)
    {
        var phone = PhoneNormalizer.Normalize(request.Phone);
        var cafeName = request.CafeName.Trim();

        var redis = _redis.GetDatabase();
        var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);

        if (_http.HttpContext is not null)
        {
            var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
            var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
            if (!ipCheck.Allowed)
                return (false, null, ipCheck.ErrorCode, ipCheck.Message);
        }

        // Check if this phone already owns a cafe — suggest login instead
        var alreadyOwner = await _db.Employees
            .AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
        if (alreadyOwner)
            return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");

        var attemptsKey = $"otp:attempts:{phone}";
        if (maxAttempts > 0)
        {
            var attempts = await redis.StringGetAsync(attemptsKey);
            if (attempts.HasValue && (int)attempts >= maxAttempts)
                return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
        }

        var otp = Random.Shared.Next(100000, 999999).ToString();
        await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
        // Store the cafe name alongside the OTP so verify-register can create the cafe
        await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));

        try
        {
            await _smsService.SendOtpAsync(phone, otp, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send registration OTP SMS");
            return (false, null, "SMS_FAILED", "Could not send verification code.");
        }

        if (maxAttempts > 0)
        {
            var newAttempts = await redis.StringIncrementAsync(attemptsKey);
            if (newAttempts == 1)
                await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
        }

        _logger.LogInformation("Registration OTP sent for phone ending {Suffix}", phone[^4..]);
        return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync(
        VerifyRegisterRequest request,
        CancellationToken cancellationToken = default)
    {
        var phone = PhoneNormalizer.Normalize(request.Phone);
        var code = OtpNormalizer.Normalize(request.Code);
        if (!OtpNormalizer.IsValidSixDigitCode(code))
            return (false, null, "INVALID_OTP", "Invalid or expired verification code.");

        var redis = _redis.GetDatabase();

        var storedOtp = await redis.StringGetAsync($"otp:{phone}");
        if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
            return (false, null, "INVALID_OTP", "Invalid or expired verification code.");

        var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
        if (string.IsNullOrWhiteSpace(cafeName))
            return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");

        // Double-check no owner was created in the meantime (race condition guard)
        var alreadyOwner = await _db.Employees
            .AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
        if (alreadyOwner)
        {
            await redis.KeyDeleteAsync($"otp:{phone}");
            await redis.KeyDeleteAsync($"reg_meta:{phone}");
            return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
        }

        // Generate a unique slug
        var slug = await GenerateUniqueSlugAsync(cancellationToken);

        var cafe = new Cafe
        {
            Name     = cafeName,
            Slug     = slug,
            PreferredLanguage = "fa",
            PlanTier = PlanTier.Free,
        };

        var owner = new Employee
        {
            CafeId = cafe.Id,
            Name   = cafeName,   // owner display name defaults to cafe name until they update it
            Phone  = phone,
            Role   = EmployeeRole.Owner,
        };

        _db.Cafes.Add(cafe);
        _db.Employees.Add(owner);
        await _db.SaveChangesAsync(cancellationToken);

        await redis.KeyDeleteAsync($"otp:{phone}");
        await redis.KeyDeleteAsync($"reg_meta:{phone}");

        _logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]);

        var ownerMembership = new List<CafeMembershipDto>
        {
            new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString())
        };
        var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken);
        return (true, tokens, null, null);
    }

    private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
    {
        string slug;
        do
        {
            // e.g. "cafe-a3f9b2c"
            slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
        } while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
        return slug;
    }

    private async Task<AuthTokenResponse> IssueTokensAsync(
        Core.Entities.Employee employee,
        Core.Entities.Cafe cafe,
        List<CafeMembershipDto>? memberships,
        CancellationToken cancellationToken)
    {
        var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
        var refreshToken = _jwtTokenService.CreateRefreshToken();
        var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);

        await _refreshTokenStore.StoreAsync(
            refreshToken,
            new RefreshTokenPayload(
                employee.Id,
                cafe.Id,
                employee.Role.ToString(),
                cafe.PlanTier.ToString(),
                cafe.PreferredLanguage,
                Meezi.Core.Constants.MeeziActorKinds.Merchant),
            TimeSpan.FromDays(refreshDays),
            cancellationToken);

        return new AuthTokenResponse(
            accessToken,
            refreshToken,
            _jwtTokenService.GetAccessTokenExpiry(),
            employee.Id,
            cafe.Id,
            employee.Role.ToString(),
            cafe.PlanTier.ToString(),
            cafe.PreferredLanguage,
            Meezi.Core.Constants.MeeziActorKinds.Merchant,
            employee.BranchId,
            memberships);
    }
}
