using Meezi.API.Models.Auth;
using Meezi.API.Security;
using Meezi.Core.Authorization;
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, null, 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, null, cancellationToken);
        return (true, tokens, null, null);
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync(
        string employeeId, string cafeId, string? targetBranchId,
        CancellationToken cancellationToken = default)
    {
        var employee = await _db.Employees
            .Include(e => e.Cafe)
            .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, cancellationToken);
        if (employee?.Cafe is null)
            return (false, null, "NOT_FOUND", "User not found.");

        // null target = café-wide (Owner only)
        if (string.IsNullOrWhiteSpace(targetBranchId))
        {
            if (employee.Role != EmployeeRole.Owner)
                return (false, null, "BRANCH_FORBIDDEN", "Only owners can operate café-wide.");
        }
        else
        {
            var branchExists = await _db.Branches
                .AnyAsync(b => b.Id == targetBranchId && b.CafeId == cafeId && b.DeletedAt == null, cancellationToken);
            if (!branchExists)
                return (false, null, "NOT_FOUND", "Branch not found.");

            if (employee.Role != EmployeeRole.Owner)
            {
                var assigned = await _db.EmployeeBranchRoles
                    .AnyAsync(r => r.EmployeeId == employeeId && r.BranchId == targetBranchId && r.DeletedAt == null, cancellationToken);
                if (!assigned && employee.BranchId != targetBranchId)
                    return (false, null, "BRANCH_FORBIDDEN", "You don't have access to this branch.");
            }
        }

        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, targetBranchId, 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, payload.ActiveBranchId, 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));

        // Determine the requested slug: use provided slug, or auto-derive from café name.
        // Format stored: "cafeName||slug" (double-pipe delimiter). Slug may be empty.
        var requestedSlug = string.IsNullOrWhiteSpace(request.Slug)
            ? Meezi.Core.Utilities.SlugHelper.Slugify(cafeName)
            : request.Slug.Trim().ToLowerInvariant();

        var regMeta = $"{cafeName}||{requestedSlug}";
        await redis.StringSetAsync($"reg_meta:{phone}", regMeta, 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 regMetaRaw = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
        if (string.IsNullOrWhiteSpace(regMetaRaw))
            return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");

        // Parse "cafeName||slug" format (double-pipe delimiter)
        string cafeName;
        string? requestedSlug;
        var sepIdx = regMetaRaw.IndexOf("||", StringComparison.Ordinal);
        if (sepIdx >= 0)
        {
            cafeName = regMetaRaw[..sepIdx];
            requestedSlug = regMetaRaw[(sepIdx + 2)..];
        }
        else
        {
            cafeName = regMetaRaw;
            requestedSlug = null;
        }

        // 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 (try requested slug first, fall back to random)
        var slug = await GenerateUniqueSlugAsync(requestedSlug, cancellationToken);

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

        // Auto-create a default main branch so the owner can start using the
        // dashboard immediately without hitting the "select a branch" gate.
        var defaultBranch = new Branch
        {
            CafeId   = cafe.Id,
            Name     = cafeName,
            IsActive = true,
        };

        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.Branches.Add(defaultBranch);
        _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, null, cancellationToken);
        return (true, tokens, null, null);
    }

    private async Task<string> GenerateUniqueSlugAsync(string? preferred, CancellationToken ct)
    {
        // Try the preferred/derived slug first
        if (Meezi.Core.Utilities.SlugHelper.IsValidSlug(preferred))
        {
            if (!await _db.Cafes.AnyAsync(c => c.Slug == preferred, ct))
                return preferred!;

            // Preferred slug is taken — append a short random suffix
            for (var i = 0; i < 10; i++)
            {
                var candidate = $"{preferred}-{Guid.NewGuid().ToString("N")[..4]}";
                if (!await _db.Cafes.AnyAsync(c => c.Slug == candidate, ct))
                    return candidate;
            }
        }

        // Full random fallback
        string slug;
        do
        {
            slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
        } while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
        return slug;
    }

    public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
        LoginWithPasswordRequest request,
        CancellationToken cancellationToken = default)
    {
        var username = request.Username.Trim();

        var candidates = await _db.Employees
            .Include(e => e.Cafe)
            .Where(e => e.Username == username
                     && e.PasswordHash != null
                     && e.DeletedAt == null
                     && e.Cafe.DeletedAt == null)
            .ToListAsync(cancellationToken);

        if (candidates.Count == 0)
            return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);

        // Constant-time verification (check all matches to avoid username enumeration)
        var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();

        if (matched.Count == 0)
            return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);

        // Scope to a specific café if requested
        if (!string.IsNullOrWhiteSpace(request.CafeId))
        {
            matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
            if (matched.Count == 0)
                return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
        }

        // Multiple cafés — ask frontend to pick one
        if (matched.Count > 1)
        {
            var choices = new CafeChoicesResponse(
                matched
                    .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 = matched[0];
        if (employee.Cafe is null)
            return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);

        var membershipDtos = matched
            .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, null, cancellationToken);
        return (true, tokens, null, null, null);
    }

    private async Task<AuthTokenResponse> IssueTokensAsync(
        Core.Entities.Employee employee,
        Core.Entities.Cafe cafe,
        List<CafeMembershipDto>? memberships,
        string? requestedBranchId,
        CancellationToken cancellationToken)
    {
        var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);

        var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
        var refreshToken = _jwtTokenService.CreateRefreshToken();
        var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);

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

        var permissions = Meezi.Core.Authorization.RolePermissions
            .For(resolution.EffectiveRole)
            .Select(p => p.ToString())
            .OrderBy(p => p)
            .ToList();

        return new AuthTokenResponse(
            accessToken,
            refreshToken,
            _jwtTokenService.GetAccessTokenExpiry(),
            employee.Id,
            cafe.Id,
            resolution.EffectiveRole.ToString(),
            cafe.PlanTier.ToString(),
            cafe.PreferredLanguage,
            Meezi.Core.Constants.MeeziActorKinds.Merchant,
            resolution.ActiveBranchId,
            memberships,
            resolution.ActiveBranchName,
            resolution.IsCafeWide,
            resolution.Branches,
            permissions);
    }

    private sealed record BranchResolution(
        EmployeeRole EffectiveRole,
        string? ActiveBranchId,
        string? ActiveBranchName,
        bool IsCafeWide,
        List<BranchMembershipDto> Branches);

    /// <summary>
    /// Determine the active branch, the role the employee holds there, and the
    /// full list of branches they may operate as. Owners are café-wide by default
    /// (null active branch) but may scope to a specific branch. Other staff are
    /// resolved from their <see cref="EmployeeBranchRole"/> assignments, falling
    /// back to the legacy single <see cref="Employee.BranchId"/> pin.
    /// </summary>
    private async Task<BranchResolution> ResolveBranchAsync(
        Core.Entities.Employee employee,
        Core.Entities.Cafe cafe,
        string? requestedBranchId,
        CancellationToken ct)
    {
        var cafeBranches = await _db.Branches
            .Where(b => b.CafeId == cafe.Id && b.DeletedAt == null && b.IsActive)
            .OrderBy(b => b.Name)
            .Select(b => new { b.Id, b.Name })
            .ToListAsync(ct);

        var branchNames = cafeBranches.ToDictionary(b => b.Id, b => b.Name);

        // Owner = café-wide. May optionally scope to a branch when requested & valid.
        if (employee.Role == EmployeeRole.Owner)
        {
            var ownerBranches = cafeBranches
                .Select(b => new BranchMembershipDto(b.Id, b.Name, EmployeeRole.Owner.ToString()))
                .ToList();

            if (!string.IsNullOrWhiteSpace(requestedBranchId) && branchNames.TryGetValue(requestedBranchId, out var rname))
                return new BranchResolution(EmployeeRole.Owner, requestedBranchId, rname, false, ownerBranches);

            return new BranchResolution(EmployeeRole.Owner, null, null, true, ownerBranches);
        }

        // Non-owner: explicit per-branch role assignments, plus the legacy pin as a fallback.
        var assignments = await _db.EmployeeBranchRoles
            .Where(r => r.EmployeeId == employee.Id && r.DeletedAt == null)
            .Select(r => new { r.BranchId, r.Role })
            .ToListAsync(ct);

        var membershipMap = new Dictionary<string, EmployeeRole>();
        foreach (var a in assignments)
            membershipMap[a.BranchId] = a.Role;

        if (!string.IsNullOrWhiteSpace(employee.BranchId) && !membershipMap.ContainsKey(employee.BranchId))
            membershipMap[employee.BranchId] = employee.Role;

        var branches = membershipMap
            .Where(kv => branchNames.ContainsKey(kv.Key))
            .Select(kv => new BranchMembershipDto(kv.Key, branchNames[kv.Key], kv.Value.ToString()))
            .OrderBy(b => b.BranchName)
            .ToList();

        // 1. Honour an explicit, valid request.
        if (!string.IsNullOrWhiteSpace(requestedBranchId)
            && membershipMap.TryGetValue(requestedBranchId, out var reqRole)
            && branchNames.TryGetValue(requestedBranchId, out var reqName))
        {
            return new BranchResolution(reqRole, requestedBranchId, reqName, false, branches);
        }

        // 2/3. One or many memberships → default to the first (frontend can switch).
        if (branches.Count >= 1)
        {
            var first = branches[0];
            return new BranchResolution(membershipMap[first.BranchId], first.BranchId, first.BranchName, false, branches);
        }

        // 4. No assignments and no pin → back-compat: café role, no branch claim (isolation off).
        return new BranchResolution(employee.Role, null, null, false, branches);
    }
}
