using System.Text.Json;
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;

namespace JobTrackerApi.Controllers;

[ApiController]
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
    private readonly IConfiguration _cfg;
    private readonly UserManager<ApplicationUser> _users;
    private readonly ITokenService _tokens;
    private readonly IAppEmailSender _email;
    private readonly IGoogleTokenValidator _googleTokens;
    private readonly IMicrosoftTokenValidator _microsoftTokens;
    private readonly ILogger<AuthController> _logger;
    private readonly ITwoFactorPendingTokenService _twoFactorPending;
    private readonly JobTrackerContext _db;
    private readonly string _avatarDataRoot;
    private readonly IHttpClientFactory? _httpClients;
    private readonly ExternalOrigin _externalOrigin;

    public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null, ExternalOrigin? externalOrigin = null)
    {
        _cfg = cfg;
        _users = users;
        _tokens = tokens;
        _email = email;
        _googleTokens = googleTokens;
        _microsoftTokens = microsoftTokens;
        _logger = logger;
        _twoFactorPending = twoFactorPending;
        _db = db;
        _httpClients = httpClients;
        _externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
        _avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
    }

    [HttpGet("config")]
    [AllowAnonymous]
    public IActionResult Config()
    {
        var requireAuth = _cfg.GetValue("Auth:Require", false);
        var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
        var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
        var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
        var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
        var turnstileSiteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
        var turnstileEnabled = turnstileSiteKey.Length > 0 && !string.IsNullOrWhiteSpace(_cfg["Turnstile:SecretKey"]);

        return Ok(new
        {
            requireAuth,
            googleEnabled,
            microsoftEnabled,
            localEnabled = true,
            allowRegistration,
            requireEmailVerification,
            turnstileEnabled,
            turnstileSiteKey = turnstileEnabled ? turnstileSiteKey : null,
        });
    }

    public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
    public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
    public sealed record AuthSessionResult(bool Authenticated, string Provider);
    public sealed record RegistrationPendingResult(bool VerificationRequired);
    public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
    public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
    public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
    public sealed record MeResult(
        string Provider,
        string? Id,
        string? Email,
        string? UserName,
        string? FirstName,
        string? LastName,
        string? DisplayName,
        string? ProfileCvText,
        string? ProfileCvStructureJson,
        string? AvatarImageDataUrl,
        IList<string> Roles,
        string Plan,
        AccountEntitlements Entitlements,
        GoogleLinkDto? GoogleLink,
        MicrosoftLinkDto? MicrosoftLink)
    {
        public string AppVersion { get; init; } = "unknown";
        public string? AppCommitSha { get; init; }
        public string UiLanguage { get; init; } = "en";
    }
    public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
    private const int MaxAvatarBytes = 1_000_000;
    private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
    {
        ".png", ".jpg", ".jpeg", ".webp"
    };
    public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
    public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
    public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true, string? CurrentPassword = null);
    public sealed record MicrosoftLegacyRelinkRequiredResult(bool LegacyRelinkRequired);
    public sealed record ConfirmMicrosoftLegacyRelinkRequest(string UserId, string TenantId, string ObjectId, string RecoveryToken, string MicrosoftToken);
    public sealed record MicrosoftUnlinkRequest(string CurrentPassword);

    [HttpPost("login")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
    {
        var email = (request.Email ?? string.Empty).Trim();
        var password = request.Password ?? string.Empty;

        if (email.Length == 0) return BadRequest("Email is required.");
        if (password.Length == 0) return BadRequest("Password is required.");
        if (!await VerifyTurnstileAsync(request.TurnstileToken, "login", cancellationToken)) return BadRequest("Security verification failed. Please try again.");

        var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
        if (user is null) return Unauthorized();

        // Same generic 401 whether the account doesn't exist, is locked out, or the password is
        // wrong -- don't let a client distinguish "locked" from "wrong password" (enumeration).
        if (await _users.IsLockedOutAsync(user)) return Unauthorized();

        var ok = await _users.CheckPasswordAsync(user, password);
        if (!ok)
        {
            await _users.AccessFailedAsync(user);
            return Unauthorized();
        }

        await _users.ResetAccessFailedCountAsync(user);

        // Same enumeration-avoidance discipline as the password-check branch above: this only
        // runs once the password is already confirmed correct, so it can never be used to probe
        // whether an email is registered.
        if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
        {
            return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
        }

        return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
    }

    [HttpPost("register")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-login")]
    public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
    {
        var allow = _cfg.GetValue("Auth:AllowRegistration", false);
        if (!allow) return StatusCode(403, "Registration is disabled.");

        var email = (request.Email ?? string.Empty).Trim();
        var password = request.Password ?? string.Empty;

        if (email.Length == 0) return BadRequest("Email is required.");
        if (password.Length == 0) return BadRequest("Password is required.");
        if (!await VerifyTurnstileAsync(request.TurnstileToken, "register", cancellationToken)) return BadRequest("Security verification failed. Please try again.");

        var existing = await _users.FindByEmailAsync(email);
        if (existing is not null) return BadRequest("User already exists.");

        var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
        var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification };
        var res = await _users.CreateAsync(user, password);
        if (!res.Succeeded)
        {
            return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
        }

        if (requireEmailVerification)
        {
            try
            {
                await SendVerificationEmailAsync(user, cancellationToken);
            }
            catch (Exception ex)
            {
                // ponytail: don't fail registration over a flaky email send -- the account is
                // created either way, the user can request a fresh link via resend-verification-email.
                _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
            }

            return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
        }

        return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
    }

    private async Task<bool> VerifyTurnstileAsync(string? token, string expectedAction, CancellationToken cancellationToken)
    {
        var secret = (_cfg["Turnstile:SecretKey"] ?? string.Empty).Trim();
        var siteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
        if (secret.Length == 0 && siteKey.Length == 0) return true;
        if (secret.Length == 0 || siteKey.Length == 0) return false;
        if (string.IsNullOrWhiteSpace(token) || token.Length > 2048 || _httpClients is null) return false;

        try
        {
            using var content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["secret"] = secret,
                ["response"] = token.Trim(),
                ["remoteip"] = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty,
            });
            using var response = await _httpClients.CreateClient().PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", content, cancellationToken);
            if (!response.IsSuccessStatusCode) return false;
            using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
            return json.RootElement.TryGetProperty("success", out var success) && success.GetBoolean()
                && json.RootElement.TryGetProperty("action", out var action)
                && string.Equals(action.GetString(), expectedAction, StringComparison.Ordinal);
        }
        catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
        {
            _logger.LogWarning(ex, "Turnstile verification failed");
            return false;
        }
    }

    [HttpPost("google/exchange")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-login")]
    public async Task<IActionResult> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
    {
        var token = (request.Token ?? string.Empty).Trim();
        if (token.Length == 0) return BadRequest("Google token is required.");

        GoogleTokenPrincipal google;
        try
        {
            google = await _googleTokens.ValidateAsync(token, cancellationToken);
        }
        catch (Exception ex)
        {
            return Unauthorized(ex.Message);
        }

        var user = await _users.Users.FirstOrDefaultAsync(
            x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email),
            cancellationToken);

        if (user is null && google.EmailVerified && !string.IsNullOrWhiteSpace(google.Email))
        {
            user = await _users.FindByEmailAsync(google.Email);
            if (user is not null)
            {
                _logger.LogInformation("Auto-linking Google sign-in for existing local account {Email}", google.Email);
            }
        }

        if (user is null)
        {
            if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email))
            {
                return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
            }

            var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
            if (!allowRegistration)
            {
                return Unauthorized("This Google account is not linked to a Jobbjakt user yet.");
            }

            user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true };
            var created = await _users.CreateAsync(user);
            if (!created.Succeeded)
            {
                return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
            }
            _logger.LogInformation("Created new user via Google sign-up for {Email}", google.Email);
        }

        if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
        {
            user.GoogleSubject = google.Subject;
            user.GoogleEmail = google.Email;
            user.GoogleLinkedAt ??= DateTimeOffset.UtcNow;
            user.DisplayName ??= TrimOrNull(google.Name);
            user.FirstName ??= TrimOrNull(google.GivenName);
            user.LastName ??= TrimOrNull(google.FamilyName);
            await _users.UpdateAsync(user);
        }

        return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken);
    }

    [HttpPost("microsoft/exchange")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-login")]
    public async Task<IActionResult> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
    {
        var token = (request.Token ?? string.Empty).Trim();
        if (token.Length == 0) return BadRequest("Microsoft token is required.");

        MicrosoftTokenPrincipal microsoft;
        try
        {
            microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
        }
        catch (Exception ex)
        {
            return Unauthorized(ex.Message);
        }

        if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
            return Unauthorized("Microsoft token is missing its stable identity.");

        var user = await _users.Users.FirstOrDefaultAsync(
            x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId,
            cancellationToken);

        if (user is null)
        {
            var legacyCandidates = await _users.Users
                .Where(x => x.MicrosoftTenantId == null && x.MicrosoftObjectId == null)
                .Where(x => x.MicrosoftSubject == objectId || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email))
                .ToListAsync(cancellationToken);
            if (legacyCandidates.Count > 1)
                return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
            if (legacyCandidates.Count == 1)
            {
                var legacy = legacyCandidates[0];
                if (!legacy.EmailConfirmed || string.IsNullOrWhiteSpace(legacy.Email))
                    return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");

                var purpose = MicrosoftLegacyRelinkPurpose(tenantId, objectId);
                var recoveryToken = await _users.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, purpose);
                var link = _externalOrigin.BuildPath($"/microsoft-legacy-relink?userId={Uri.EscapeDataString(legacy.Id)}&tenantId={Uri.EscapeDataString(tenantId)}&objectId={Uri.EscapeDataString(objectId)}&token={Uri.EscapeDataString(recoveryToken)}");
                try
                {
                    await _email.SendAsync(
                        legacy.Email,
                        "Confirm your Microsoft account relink",
                        $"A tenant-qualified Microsoft account requested access to your Jobbjakt account. If this was you, open the link and authenticate with the same Microsoft account:\n\n{link}\n\nIf this was not you, ignore this email.",
                        cancellationToken);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to send Microsoft legacy-relink proof");
                    return EmailDeliveryUnavailable("Microsoft account recovery email could not be sent right now. Please try again later.");
                }
                return Accepted(new MicrosoftLegacyRelinkRequiredResult(true));
            }
        }

        if (user is null)
        {
            if (string.IsNullOrWhiteSpace(microsoft.Email))
            {
                return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
            }

            var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
            if (!allowRegistration)
            {
                return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
            }

            if (await _users.FindByEmailAsync(microsoft.Email) is not null)
                return Conflict("Sign in to the existing Jobbjakt account before linking Microsoft.");

            user = new ApplicationUser
            {
                UserName = microsoft.Email,
                Email = microsoft.Email,
                EmailConfirmed = false,
                MicrosoftTenantId = tenantId,
                MicrosoftObjectId = objectId,
                MicrosoftEmail = microsoft.Email,
                MicrosoftLinkedAt = DateTimeOffset.UtcNow,
                DisplayName = TrimOrNull(microsoft.Name),
                FirstName = TrimOrNull(microsoft.GivenName),
                LastName = TrimOrNull(microsoft.FamilyName),
            };
            var created = await _users.CreateAsync(user);
            if (!created.Succeeded)
            {
                return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
            }
            _logger.LogInformation("Created a new tenant-qualified Microsoft user");

            if (_cfg.GetValue("Auth:RequireEmailVerification", false))
            {
                try { await SendVerificationEmailAsync(user, cancellationToken); }
                catch (Exception ex) { _logger.LogError(ex, "Failed to send verification email for Microsoft registration"); }
                return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
            }
        }

        if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
            return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });

        user.MicrosoftEmail = microsoft.Email;
        user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
        user.DisplayName ??= TrimOrNull(microsoft.Name);
        user.FirstName ??= TrimOrNull(microsoft.GivenName);
        user.LastName ??= TrimOrNull(microsoft.FamilyName);
        var metadataUpdate = await _users.UpdateAsync(user);
        if (!metadataUpdate.Succeeded) return BadRequest(string.Join("; ", metadataUpdate.Errors.Select(x => x.Description)));

        return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
    }

    [HttpPost("microsoft/legacy-relink/confirm")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> ConfirmMicrosoftLegacyRelink([FromBody] ConfirmMicrosoftLegacyRelinkRequest request, CancellationToken cancellationToken)
    {
        MicrosoftTokenPrincipal microsoft;
        try { microsoft = await _microsoftTokens.ValidateAsync(request.MicrosoftToken, cancellationToken); }
        catch (Exception ex) { return BadRequest(ex.Message); }
        if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId)
            || !string.Equals(tenantId, request.TenantId, StringComparison.OrdinalIgnoreCase)
            || !string.Equals(objectId, request.ObjectId, StringComparison.OrdinalIgnoreCase))
            return BadRequest("Microsoft identity does not match this recovery link.");

        var user = await _users.FindByIdAsync(request.UserId);
        if (user is null || user.MicrosoftTenantId is not null || user.MicrosoftObjectId is not null)
            return BadRequest("Invalid or expired recovery link.");
        if (!await _users.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, MicrosoftLegacyRelinkPurpose(tenantId, objectId), request.RecoveryToken))
            return BadRequest("Invalid or expired recovery link.");
        if (await _users.Users.AnyAsync(x => x.Id != user.Id && x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken))
            return Conflict("That Microsoft account is already linked to another Jobbjakt user.");

        user.MicrosoftTenantId = tenantId;
        user.MicrosoftObjectId = objectId;
        user.MicrosoftEmail = microsoft.Email;
        user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
        var update = await _users.UpdateAsync(user);
        if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
        await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
        ClearSessionCookies();
        TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
        return NoContent();
    }

    [HttpPost("logout")]
    // Anonymous on purpose, and now explicitly: this only clears the caller's own session cookies and
    // leaks nothing. Requiring authentication would mean a user whose token has already expired gets a
    // 401 when signing out and stays stuck in a half-signed-in state.
    [AllowAnonymous]
    public async Task<IActionResult> Logout(CancellationToken cancellationToken)
    {
        var cookieToken = Request.Cookies[AuthSessionOptions.SessionCookieName];
        if (SessionRevocation.TryReadIdentity(User, cookieToken, out var userId, out var sessionId))
            await SessionRevocation.RevokeCurrentAsync(_db, userId, sessionId, cancellationToken);

        ClearSessionCookies();
        return NoContent();
    }

    [HttpGet("csrf")]
    [AllowAnonymous]
    public IActionResult EnsureCsrfCookie()
    {
        EnsureCsrfCookie(false);
        return NoContent();
    }

    [HttpGet("me")]
    [Authorize]
    public async Task<IActionResult> Me(CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is not null)
        {
            var roles = await _users.GetRolesAsync(user);
            var isAdmin = roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
            return Ok(WithBuildMetadata(ToMeResult(user, roles), isAdmin));
        }

        var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
        var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
        var iss = User.FindFirstValue("iss") ?? string.Empty;
        var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase)
            ? "google"
            : iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase)
                ? "microsoft"
                : "external";

        return Ok(WithBuildMetadata(new MeResult(
            Provider: provider,
            Id: sub,
            Email: email,
            UserName: null,
            FirstName: User.FindFirstValue("given_name"),
            LastName: User.FindFirstValue("family_name"),
            DisplayName: User.FindFirstValue("name"),
            ProfileCvText: null,
            ProfileCvStructureJson: null,
            AvatarImageDataUrl: null,
            Roles: Array.Empty<string>(),
            Plan: "free",
            Entitlements: AccountPlans.ForRoles(Array.Empty<string>()),
            GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
            MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null), false));
    }

    [HttpPut("profile")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
        }

        // Partial update. A field is only touched when the request actually carries it:
        //   - null / omitted  -> leave unchanged (the caller isn't editing this field)
        //   - ""              -> explicitly clear
        //   - "value"         -> set (trimmed)
        // This lets /profile save identity fields and /career save the master-profile fields
        // through the same endpoint without one wiping the other. Email and UserName are the
        // login identifiers and are never cleared to empty. Email ownership changes use the
        // separate, token-confirmed email-change flow below.
        if (request.UserName is not null)
        {
            var v = request.UserName.Trim();
            if (v.Length > 0) user.UserName = v;
        }
        if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName);
        if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName);
        if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName);
        if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText);
        if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);

        var res = await _users.UpdateAsync(user);
        if (!res.Succeeded)
            return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));

        return NoContent();
    }

    public sealed record UpdateLanguagePreferenceRequest(string? Language);

    [HttpPut("preferences/language")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null) return Unauthorized();
        var language = NormalizeUiLanguage(request?.Language);
        if (language is null) return BadRequest("Supported languages are en and nb-NO.");
        user.UiLanguage = language;
        var result = await _users.UpdateAsync(user);
        return result.Succeeded ? NoContent() : BadRequest(string.Join("; ", result.Errors.Select(error => error.Description)));
    }

    public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
    public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
    public sealed record CancelEmailChangeRequest(string CurrentPassword);

    [HttpGet("email-change")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<ActionResult<PendingEmailChangeResult>> GetEmailChange()
    {
        var user = await _users.GetUserAsync(User);
        if (user is null) return Unauthorized();
        return Ok(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
    }

    [HttpPost("email-change/request")]
    [Authorize(AuthenticationSchemes = "local")]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> RequestEmailChange([FromBody] RequestEmailChangeRequest request, CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null) return Unauthorized();
        if (!user.EmailConfirmed) return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
        if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
            return BadRequest("Current password is incorrect.");

        var newEmail = (request.Email ?? string.Empty).Trim();
        if (newEmail.Length > 320 || !new EmailAddressAttribute().IsValid(newEmail)) return BadRequest("A valid email is required.");
        if (string.Equals(_users.NormalizeEmail(newEmail), _users.NormalizeEmail(user.Email), StringComparison.Ordinal)) return BadRequest("The new email must be different.");

        var existing = await _users.FindByEmailAsync(newEmail);
        if (existing is not null && !string.Equals(existing.Id, user.Id, StringComparison.Ordinal)) return BadRequest("Email is already in use.");

        user.PendingEmail = newEmail;
        user.PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow;
        user.SecurityStamp = Guid.NewGuid().ToString();
        var update = await _users.UpdateAsync(user);
        if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));

        var token = await _users.GenerateChangeEmailTokenAsync(user, newEmail);
        var link = _externalOrigin.BuildPath($"/confirm-email-change?userId={Uri.EscapeDataString(user.Id)}&email={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(token)}");
        try
        {
            await _email.SendAsync(newEmail, "Confirm your new email", $"Confirm this email address for your Jobbjakt account:\n\n{link}\n\nIf you did not request this change, ignore this email.", cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send an email-change confirmation");
            return EmailDeliveryUnavailable("The confirmation email could not be sent right now. Please try again later.");
        }

        if (!string.IsNullOrWhiteSpace(user.Email))
        {
            try
            {
                await _email.SendAsync(user.Email, "Email change requested", "A change to the email address on your Jobbjakt account was requested. Your current email remains active until the new address is confirmed.", cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to send the current-address email-change notice");
            }
        }

        return Accepted(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
    }

    [HttpPost("email-change/confirm")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request, CancellationToken cancellationToken)
    {
        var userId = (request.UserId ?? string.Empty).Trim();
        var newEmail = (request.Email ?? string.Empty).Trim();
        var token = request.Token ?? string.Empty;
        if (userId.Length == 0 || newEmail.Length == 0 || token.Length == 0) return BadRequest("Invalid or expired link.");

        var user = await _users.FindByIdAsync(userId);
        if (user is null || !string.Equals(_users.NormalizeEmail(user.PendingEmail), _users.NormalizeEmail(newEmail), StringComparison.Ordinal))
            return BadRequest("Invalid or expired link.");

        var oldEmail = user.Email;
        var updateUserName = string.Equals(_users.NormalizeName(user.UserName), _users.NormalizeEmail(oldEmail), StringComparison.Ordinal);
        var transaction = _db.Database.IsRelational() ? await _db.Database.BeginTransactionAsync(cancellationToken) : null;
        try
        {
            var changed = await _users.ChangeEmailAsync(user, newEmail, token);
            if (!changed.Succeeded) return BadRequest("Invalid or expired link.");

            if (updateUserName)
            {
                var renamed = await _users.SetUserNameAsync(user, newEmail);
                if (!renamed.Succeeded) return BadRequest(string.Join("; ", renamed.Errors.Select(x => x.Description)));
            }

            user.PendingEmail = null;
            user.PendingEmailRequestedAtUtc = null;
            var cleared = await _users.UpdateAsync(user);
            if (!cleared.Succeeded) return BadRequest(string.Join("; ", cleared.Errors.Select(x => x.Description)));

            await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
            if (transaction is not null) await transaction.CommitAsync(cancellationToken);
        }
        finally
        {
            if (transaction is not null) await transaction.DisposeAsync();
        }

        ClearSessionCookies();
        TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
        if (!string.IsNullOrWhiteSpace(oldEmail))
        {
            try
            {
                await _email.SendAsync(oldEmail, "Your Jobbjakt email changed", "The email address on your Jobbjakt account was changed. If this was not you, reset your password and contact the administrator.", cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to send the old-address email-change notice");
            }
        }

        return NoContent();
    }

    [HttpPost("email-change/cancel")]
    [Authorize(AuthenticationSchemes = "local")]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> CancelEmailChange([FromBody] CancelEmailChangeRequest request)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null) return Unauthorized();
        if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
            return BadRequest("Current password is incorrect.");

        user.PendingEmail = null;
        user.PendingEmailRequestedAtUtc = null;
        var update = await _users.UpdateAsync(user);
        if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
        return NoContent();
    }

    [HttpPost("google/link")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        var token = (request.Token ?? string.Empty).Trim();
        if (token.Length == 0) return BadRequest("Google token is required.");

        GoogleTokenPrincipal google;
        try
        {
            google = await _googleTokens.ValidateAsync(token, cancellationToken);
        }
        catch (Exception ex)
        {
            return BadRequest(ex.Message);
        }

        var conflict = await _users.Users
            .Where(x => x.Id != user.Id)
            .FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
        if (conflict is not null)
        {
            return Conflict("That Google account is already linked to another Jobbjakt user.");
        }

        user.GoogleSubject = google.Subject;
        user.GoogleEmail = google.Email;
        user.GoogleLinkedAt = DateTimeOffset.UtcNow;
        user.DisplayName ??= TrimOrNull(google.Name);
        user.FirstName ??= TrimOrNull(google.GivenName);
        user.LastName ??= TrimOrNull(google.FamilyName);

        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt));
    }

    [HttpDelete("google/link")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> UnlinkGoogle()
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        user.GoogleSubject = null;
        user.GoogleEmail = null;
        user.GoogleLinkedAt = null;

        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        return NoContent();
    }

    [HttpPost("microsoft/link")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<ActionResult<MicrosoftLinkDto>> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        var token = (request.Token ?? string.Empty).Trim();
        if (token.Length == 0) return BadRequest("Microsoft token is required.");

        MicrosoftTokenPrincipal microsoft;
        try
        {
            microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken);
        }
        catch (Exception ex)
        {
            return BadRequest(ex.Message);
        }

        if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
            return BadRequest("Microsoft token is missing its stable identity.");
        if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
            return BadRequest("Current password is required to link Microsoft.");

        var conflict = await _users.Users
            .Where(x => x.Id != user.Id)
            .FirstOrDefaultAsync(x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken);
        if (conflict is not null)
        {
            return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
        }

        user.MicrosoftTenantId = tenantId;
        user.MicrosoftObjectId = objectId;
        user.MicrosoftEmail = microsoft.Email;
        user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
        user.DisplayName ??= TrimOrNull(microsoft.Name);
        user.FirstName ??= TrimOrNull(microsoft.GivenName);
        user.LastName ??= TrimOrNull(microsoft.FamilyName);

        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
        ClearSessionCookies();
        TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);

        return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
    }

    [HttpDelete("microsoft/link")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> UnlinkMicrosoft([FromBody] MicrosoftUnlinkRequest request, CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
            return BadRequest("A current password is required before unlinking Microsoft.");

        user.MicrosoftSubject = null;
        user.MicrosoftTenantId = null;
        user.MicrosoftObjectId = null;
        user.MicrosoftEmail = null;
        user.MicrosoftLinkedAt = null;

        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
        ClearSessionCookies();
        TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);

        return NoContent();
    }

    [HttpPost("avatar")]
    [Authorize(AuthenticationSchemes = "local")]
    [RequestSizeLimit(MaxAvatarBytes)]
    public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        if (file is null || file.Length == 0)
        {
            return BadRequest("Image file is required.");
        }

        if (file.Length > MaxAvatarBytes)
        {
            return BadRequest("Avatar image is too large.");
        }

        var extension = Path.GetExtension(file.FileName ?? string.Empty);
        if (!AllowedAvatarExtensions.Contains(extension))
        {
            return BadRequest("Only PNG, JPEG, or WebP images are supported.");
        }

        await using var stream = file.OpenReadStream();
        using var memory = new MemoryStream();
        await stream.CopyToAsync(memory);
        var bytes = memory.ToArray();

        var detectedContentType = DetectAvatarContentType(bytes);
        if (detectedContentType is null)
        {
            return BadRequest("Only PNG, JPEG, or WebP images are supported.");
        }

        user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);

        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
    }

    [HttpDelete("avatar")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> DeleteAvatar()
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return Unauthorized();
        }

        var storedAvatar = user.AvatarImageDataUrl;
        user.AvatarImageDataUrl = null;
        var result = await _users.UpdateAsync(user);
        if (!result.Succeeded)
        {
            return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
        }

        AvatarStorage.Delete(storedAvatar);
        return NoContent();
    }

    public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);

    [HttpPost("change-password")]
    [Authorize(AuthenticationSchemes = "local")]
    public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
    {
        var user = await _users.GetUserAsync(User);
        if (user is null)
        {
            return StatusCode(501, "Password changes are only supported for local username/password accounts.");
        }

        if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required.");
        if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required.");

        var res = await _users.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
        if (!res.Succeeded)
            return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));

        var currentTrustedDevice = TrustedDeviceService.CurrentDeviceTokenHash(Request);
        await SessionRevocation.RevokeAllAsync(_db, user.Id, currentTrustedDevice, cancellationToken);
        await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, false, _externalOrigin.UsesHttps, cancellationToken);

        return NoContent();
    }

    public sealed record RequestPasswordResetRequest(string Email);

    [HttpPost("request-password-reset")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
    {
        var email = (request.Email ?? string.Empty).Trim();
        if (email.Length == 0) return NoContent();

        var user = await _users.FindByEmailAsync(email);
        if (user is null || string.IsNullOrWhiteSpace(user.Email) || !user.EmailConfirmed || !await _users.HasPasswordAsync(user))
        {
            return NoContent();
        }

        var token = await _users.GeneratePasswordResetTokenAsync(user);

        var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}");

        try
        {
            await _email.SendAsync(
                user.Email,
                "Password reset",
                $"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.",
                cancellationToken
            );
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send password reset email to {Email}", user.Email);
            return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later.");
        }

        return NoContent();
    }

    public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword);

    [HttpPost("reset-password")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken cancellationToken)
    {
        var email = (request.Email ?? string.Empty).Trim();
        var token = request.Token ?? string.Empty;
        var newPassword = request.NewPassword ?? string.Empty;

        if (email.Length == 0) return BadRequest("Email is required.");
        if (token.Length == 0) return BadRequest("Token is required.");
        if (newPassword.Length == 0) return BadRequest("NewPassword is required.");

        var user = await _users.FindByEmailAsync(email);
        if (user is null) return BadRequest("Invalid email or token.");

        var res = await _users.ResetPasswordAsync(user, token, newPassword);
        if (!res.Succeeded)
            return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));

        await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
        if (SessionRevocation.TryReadIdentity(User, Request.Cookies[AuthSessionOptions.SessionCookieName], out var currentUserId, out _)
            && string.Equals(currentUserId, user.Id, StringComparison.Ordinal))
        {
            ClearSessionCookies();
        }
        TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);

        return NoContent();
    }

    public sealed record VerifyEmailRequest(string UserId, string Token);

    [HttpPost("verify-email")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
    {
        var userId = (request.UserId ?? string.Empty).Trim();
        var token = request.Token ?? string.Empty;

        if (userId.Length == 0) return BadRequest("UserId is required.");
        if (token.Length == 0) return BadRequest("Token is required.");

        var user = await _users.FindByIdAsync(userId);
        if (user is null || user.EmailConfirmed) return BadRequest("Invalid or expired link.");

        var res = await _users.ConfirmEmailAsync(user, token);
        if (!res.Succeeded)
        {
            return BadRequest("Invalid or expired link.");
        }

        return NoContent();
    }

    public sealed record ResendVerificationEmailRequest(string Email);

    [HttpPost("resend-verification-email")]
    [AllowAnonymous]
    [EnableRateLimiting("auth-email")]
    public async Task<IActionResult> ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken)
    {
        var email = (request.Email ?? string.Empty).Trim();
        if (email.Length == 0) return NoContent();

        // Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually
        // send when there's a matching local account that still needs verifying.
        var user = await _users.FindByEmailAsync(email);
        if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user))
        {
            return NoContent();
        }

        try
        {
            await SendVerificationEmailAsync(user, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
            return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later.");
        }

        return NoContent();
    }

    private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
    {
        var token = await _users.GenerateEmailConfirmationTokenAsync(user);

        var link = _externalOrigin.BuildPath($"/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}");

        await _email.SendAsync(
            user.Email!,
            "Verify your email",
            $"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.",
            cancellationToken
        );
    }

    private IActionResult EmailDeliveryUnavailable(string detail)
    {
        return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
    }

    // Shared by local/Google/Microsoft sign-in. If the account has TOTP 2FA enabled, this does
    // NOT issue the real session -- it hands back a short-lived opaque pending token that only
    // POST /api/auth/2fa/challenge can redeem, after the caller proves they hold the TOTP device
    // (or a recovery code). This is the gate that makes 2FA actually mandatory rather than
    // decorative: skipping straight to AppSessionIssuer here would defeat the whole feature.
    private async Task<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
    {
        if (user.DeletionStatus != AccountDeletionStatuses.Active)
            return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" });
        // "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a
        // non-expired row for this exact user, skip straight to a real session, same as if 2FA
        // weren't required at all. Falls through to the normal gate for any other outcome
        // (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
        if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
        {
            await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
            return Ok(new AuthSessionResult(true, provider));
        }

        if (user.TwoFactorEnabled)
        {
            var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe, user.SecurityStamp);
            return Ok(new TwoFactorRequiredResult(true, pendingToken));
        }

        await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
        return Ok(new AuthSessionResult(true, provider));
    }

    private void EnsureCsrfCookie(bool persistent)
    {
        var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
        Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, _externalOrigin.UsesHttps));
    }

    private void ClearSessionCookies()
    {
        Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
        Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(_externalOrigin.UsesHttps));
    }

    private static string? DetectAvatarContentType(byte[] bytes)
    {
        if (bytes.Length >= 8
            && bytes[0] == 0x89
            && bytes[1] == 0x50
            && bytes[2] == 0x4E
            && bytes[3] == 0x47
            && bytes[4] == 0x0D
            && bytes[5] == 0x0A
            && bytes[6] == 0x1A
            && bytes[7] == 0x0A)
        {
            return "image/png";
        }

        if (bytes.Length >= 3
            && bytes[0] == 0xFF
            && bytes[1] == 0xD8
            && bytes[2] == 0xFF)
        {
            return "image/jpeg";
        }

        if (bytes.Length >= 12
            && bytes[0] == 0x52
            && bytes[1] == 0x49
            && bytes[2] == 0x46
            && bytes[3] == 0x46
            && bytes[8] == 0x57
            && bytes[9] == 0x45
            && bytes[10] == 0x42
            && bytes[11] == 0x50)
        {
            return "image/webp";
        }

        return null;
    }

    private static string? TrimOrNull(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
    }

    private static bool TryGetMicrosoftKey(MicrosoftTokenPrincipal principal, out string tenantId, out string objectId)
    {
        tenantId = Guid.TryParse(principal.TenantId, out var tenant) ? tenant.ToString("D") : string.Empty;
        objectId = Guid.TryParse(principal.ObjectId, out var obj) ? obj.ToString("D") : string.Empty;
        return tenantId.Length > 0 && objectId.Length > 0;
    }

    private static string MicrosoftLegacyRelinkPurpose(string tenantId, string objectId)
        => $"microsoft-legacy-relink:{tenantId}:{objectId}";

    private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
    {
        var planEntitlements = AccountPlans.ForRoles(roles);
        var entitlements = planEntitlements with { Ai = planEntitlements.Ai && user.AiEnabled };
        return new MeResult(
            Provider: "local",
            Id: user.Id,
            Email: user.Email,
            UserName: user.UserName,
            FirstName: user.FirstName,
            LastName: user.LastName,
            DisplayName: user.DisplayName,
            ProfileCvText: user.ProfileCvText,
            ProfileCvStructureJson: user.ProfileCvStructureJson,
            AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
            Roles: roles,
            Plan: AccountPlans.Name(planEntitlements),
            Entitlements: entitlements,
            GoogleLink: new GoogleLinkDto(
                Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
                Email: user.GoogleEmail,
                LinkedAt: user.GoogleLinkedAt),
            MicrosoftLink: new MicrosoftLinkDto(
                Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
                Email: user.MicrosoftEmail,
                LinkedAt: user.MicrosoftLinkedAt))
        {
            UiLanguage = NormalizeUiLanguage(user.UiLanguage) ?? "en",
        };
    }

    private static string? NormalizeUiLanguage(string? language)
    {
        var value = language?.Trim();
        if (string.Equals(value, "en", StringComparison.OrdinalIgnoreCase)) return "en";
        if (string.Equals(value, "nb", StringComparison.OrdinalIgnoreCase)
            || string.Equals(value, "nb-NO", StringComparison.OrdinalIgnoreCase)
            || string.Equals(value, "no", StringComparison.OrdinalIgnoreCase)) return "nb-NO";
        return null;
    }

    private MeResult WithBuildMetadata(MeResult result, bool include)
    {
        if (!include) return result;

        return result with
        {
            AppVersion = BuildMetadata.ResolveVersion(_cfg),
            AppCommitSha = BuildMetadata.Normalize(_cfg["App:CommitSha"]),
        };
    }
}
