using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System.Collections;
using System.Linq.Expressions;
using Xunit;

namespace JobTrackerApi.Tests;

public sealed class AuthAndSystemControllerTests
{
    [Fact]
    public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
        userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);

        var failedCount = 0;
        var lockedOut = false;
        userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut));
        userManager.Setup(x => x.AccessFailedAsync(user))
            .Callback(() =>
            {
                failedCount++;
                if (failedCount >= 5) lockedOut = true;
            })
            .ReturnsAsync(IdentityResult.Success);
        userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
        userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        for (var i = 0; i < 5; i++)
        {
            var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None);
            Assert.IsType<UnauthorizedResult>(attempt);
        }

        Assert.True(lockedOut);

        var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

        Assert.IsType<UnauthorizedResult>(sixthAttempt);
        userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never);
    }

    [Fact]
    public async Task Login_skips_two_factor_when_trusted_device_cookie_matches_current_user()
    {
        var dbName = Guid.NewGuid().ToString();
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
        const string token = "trusted-device-token";

        using (var seedDb = BuildDb(dbName, null))
        {
            seedDb.TrustedDevices.Add(new TrustedDevice
            {
                UserId = "user-1",
                TokenHash = TrustedDeviceService.HashToken(token),
                CreatedAtUtc = DateTimeOffset.UtcNow,
                LastSeenAtUtc = DateTimeOffset.UtcNow,
                ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
            });
            seedDb.SaveChanges();
        }

        var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
        using (db)
        {
            var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

            var ok = Assert.IsType<OkObjectResult>(result);
            var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
            Assert.True(session.Authenticated);
        }
    }

    [Fact]
    public async Task Login_does_not_skip_two_factor_when_trusted_device_belongs_to_different_user()
    {
        var dbName = Guid.NewGuid().ToString();
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
        const string token = "trusted-device-token";

        using (var seedDb = BuildDb(dbName, null))
        {
            seedDb.TrustedDevices.Add(new TrustedDevice
            {
                UserId = "user-2", // a different account -- must not skip 2FA for user-1
                TokenHash = TrustedDeviceService.HashToken(token),
                CreatedAtUtc = DateTimeOffset.UtcNow,
                LastSeenAtUtc = DateTimeOffset.UtcNow,
                ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
            });
            seedDb.SaveChanges();
        }

        var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
        using (db)
        {
            var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

            var ok = Assert.IsType<OkObjectResult>(result);
            Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
        }
    }

    [Fact]
    public async Task Login_does_not_skip_two_factor_when_trusted_device_is_expired()
    {
        var dbName = Guid.NewGuid().ToString();
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
        const string token = "trusted-device-token";

        using (var seedDb = BuildDb(dbName, null))
        {
            seedDb.TrustedDevices.Add(new TrustedDevice
            {
                UserId = "user-1",
                TokenHash = TrustedDeviceService.HashToken(token),
                CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
                LastSeenAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
                ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(-1), // expired yesterday
            });
            seedDb.SaveChanges();
        }

        var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
        using (db)
        {
            var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

            var ok = Assert.IsType<OkObjectResult>(result);
            Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
        }
    }

    [Fact]
    public async Task Login_does_not_skip_two_factor_when_no_trusted_device_cookie_present()
    {
        var dbName = Guid.NewGuid().ToString();
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };

        var controller = BuildLoginController(user, "correct-password", out var db, dbName, cookieToken: null);
        using (db)
        {
            var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

            var ok = Assert.IsType<OkObjectResult>(result);
            Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
        }
    }

    [Fact]
    public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Auth:AllowRegistration"] = "true",
                ["Auth:RequireEmailVerification"] = "true",
            })
            .Build();

        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
        ApplicationUser? created = null;
        userManager
            .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
            .Callback<ApplicationUser, string>((u, _) => created = u)
            .ReturnsAsync(IdentityResult.Success);
        userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var emailSender = new Mock<IAppEmailSender>();

        using var db = TestHostFactory.CreateInMemoryDb();
        var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);

        var accepted = Assert.IsType<ObjectResult>(result);
        Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode);
        Assert.True(Assert.IsType<AuthController.RegistrationPendingResult>(accepted.Value).VerificationRequired);
        Assert.NotNull(created);
        Assert.False(created!.EmailConfirmed);
        Assert.Empty(db.UserSessions);
        tokenService.Verify(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
        emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny<string>(), It.Is<string>(b => b.Contains("verify-email")), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Register_is_unchanged_when_flag_off()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
            .Build();

        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null);
        ApplicationUser? created = null;
        userManager
            .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), "password123"))
            .Callback<ApplicationUser, string>((u, _) => created = u)
            .ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var emailSender = new Mock<IAppEmailSender>();

        var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);

        Assert.IsType<OkObjectResult>(result);
        Assert.NotNull(created);
        Assert.True(created!.EmailConfirmed);
        emailSender.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task Login_rejects_unconfirmed_local_account_when_flag_on()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:RequireEmailVerification"] = "true" })
            .Build();

        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
        userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
        userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
        userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
        userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);

        var controller = new AuthController(config, userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

        var obj = Assert.IsType<ObjectResult>(result);
        Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode);
    }

    [Fact]
    public async Task Login_allows_unconfirmed_local_account_when_flag_off()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
        userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
        userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
        userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
        userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);

        var ok = Assert.IsType<OkObjectResult>(result);
        var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
        Assert.True(session.Authenticated);
    }

    [Fact]
    public async Task VerifyEmail_confirms_account_on_valid_token()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
        userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).ReturnsAsync(IdentityResult.Success);

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token"));

        Assert.IsType<NoContentResult>(result);
    }

    [Fact]
    public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user);
        userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null);
        userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
        userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token");

        var emailSender = new Mock<IAppEmailSender>();

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None);
        var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None);

        Assert.IsType<NoContentResult>(realResult);
        Assert.IsType<NoContentResult>(fakeResult);
        emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on()
    {
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Auth:AllowRegistration"] = "true",
                ["Auth:RequireEmailVerification"] = "true",
            })
            .Build();

        var userManager = CreateUserManager();
        userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
        userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
        ApplicationUser? created = null;
        userManager
            .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
            .Callback<ApplicationUser>(u => created = u)
            .ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var googleValidator = new Mock<IGoogleTokenValidator>();
        googleValidator
            .Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));

        var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);

        Assert.IsType<OkObjectResult>(result);
        Assert.NotNull(created);
        Assert.True(created!.EmailConfirmed);
    }

    private static JobTrackerContext BuildDb(string dbName, string? currentUserId)
    {
        var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(dbName).Options;
        var currentUser = new Mock<ICurrentUserService>();
        currentUser.SetupGet(x => x.UserId).Returns(currentUserId);
        return new JobTrackerContext(options, currentUser.Object);
    }

    private static AuthController BuildLoginController(ApplicationUser user, string password, out JobTrackerContext db, string dbName, string? cookieToken)
    {
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync(user.Email!)).ReturnsAsync(user);
        userManager.Setup(x => x.FindByNameAsync(user.Email!)).ReturnsAsync((ApplicationUser?)null);
        userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
        userManager.Setup(x => x.CheckPasswordAsync(user, password)).ReturnsAsync(true);
        userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        db = BuildDb(dbName, null);

        var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        if (cookieToken is not null)
        {
            controller.Request.Headers["Cookie"] = $"{AuthSessionOptions.TrustedDeviceCookieName}={cookieToken}";
        }

        return controller;
    }

    [Fact]
    public async Task Update_profile_applies_trimmed_profile_fields()
    {
        var user = new ApplicationUser { Email = "old@example.com", UserName = "olduser" };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("old@example.com", user.Email);
        Assert.Equal("newuser", user.UserName);
        Assert.Equal("Ada", user.FirstName);
        Assert.Equal("Lovelace", user.LastName);
        Assert.Equal("Ada L.", user.DisplayName);
    }

    [Fact]
    public async Task Language_preference_normalizes_and_persists_norwegian_bokmal()
    {
        var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("nb"));

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("nb-NO", user.UiLanguage);
    }

    [Fact]
    public async Task Language_preference_rejects_an_unsupported_locale()
    {
        var user = new ApplicationUser { Id = "user-1", UiLanguage = "en" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.UpdateLanguagePreference(new AuthController.UpdateLanguagePreferenceRequest("sv-SE"));

        Assert.IsType<BadRequestObjectResult>(result);
        users.Verify(x => x.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
    }

    [Fact]
    public async Task Request_email_change_keeps_current_email_and_sends_confirmation_to_new_address()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", UserName = "old@example.com", EmailConfirmed = true, SecurityStamp = "old-stamp" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
        users.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
        users.Setup(x => x.NormalizeEmail(It.IsAny<string>())).Returns((string value) => value.ToUpperInvariant());
        users.Setup(x => x.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
        users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
        users.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@example.com")).ReturnsAsync("change-token");
        var email = new Mock<IAppEmailSender>();
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), email.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.RequestEmailChange(new AuthController.RequestEmailChangeRequest("new@example.com", "correct-password"), CancellationToken.None);

        Assert.IsType<AcceptedResult>(result);
        Assert.Equal("old@example.com", user.Email);
        Assert.Equal("new@example.com", user.PendingEmail);
        Assert.NotNull(user.PendingEmailRequestedAtUtc);
        Assert.NotEqual("old-stamp", user.SecurityStamp);
        email.Verify(x => x.SendAsync("new@example.com", It.IsAny<string>(), It.Is<string>(body => body.Contains("confirm-email-change")), It.IsAny<CancellationToken>()), Times.Once);
        email.Verify(x => x.SendAsync("old@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Confirm_email_change_rejects_a_superseded_address()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", PendingEmail = "latest@example.com" };
        var users = CreateUserManager();
        users.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
        users.Setup(x => x.NormalizeEmail(It.IsAny<string>())).Returns((string value) => value.ToUpperInvariant());
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.ConfirmEmailChange(new AuthController.ConfirmEmailChangeRequest("user-1", "superseded@example.com", "old-token"), CancellationToken.None);

        Assert.IsType<BadRequestObjectResult>(result);
        users.Verify(x => x.ChangeEmailAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }

    [Fact]
    public async Task Confirm_email_change_updates_default_username_and_revokes_sessions_and_devices()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", UserName = "old@example.com", PendingEmail = "new@example.com", PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow };
        var users = CreateUserManager();
        users.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
        users.Setup(x => x.NormalizeEmail(It.IsAny<string>())).Returns((string value) => value.ToUpperInvariant());
        users.Setup(x => x.NormalizeName(It.IsAny<string>())).Returns((string value) => value.ToUpperInvariant());
        users.Setup(x => x.ChangeEmailAsync(user, "new@example.com", "change-token"))
            .Callback(() => { user.Email = "new@example.com"; user.EmailConfirmed = true; })
            .ReturnsAsync(IdentityResult.Success);
        users.Setup(x => x.SetUserNameAsync(user, "new@example.com"))
            .Callback(() => user.UserName = "new@example.com")
            .ReturnsAsync(IdentityResult.Success);
        users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);

        using var db = TestHostFactory.CreateInMemoryDb();
        db.UserSessions.Add(new UserSession { Id = "sid-1", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1) });
        db.TrustedDevices.Add(new TrustedDevice { UserId = user.Id, TokenHash = "hash", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(1) });
        await db.SaveChangesAsync();
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), db)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ConfirmEmailChange(new AuthController.ConfirmEmailChangeRequest("user-1", "new@example.com", "change-token"), CancellationToken.None);

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("new@example.com", user.Email);
        Assert.Equal("new@example.com", user.UserName);
        Assert.Null(user.PendingEmail);
        Assert.NotNull((await db.UserSessions.SingleAsync()).RevokedAtUtc);
        Assert.Empty(await db.TrustedDevices.ToListAsync());
    }

    private static AuthController BuildProfileController(ApplicationUser user)
    {
        var userManager = CreateUserManager();
        userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
        return new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
    }

    [Fact]
    public async Task Identity_only_save_does_not_wipe_the_master_cv()
    {
        // The Phase 2 /profile save omits the CV fields. A null field must leave the master CV
        // untouched -- the data-integrity guarantee behind splitting the profile/career save.
        var user = new ApplicationUser
        {
            Email = "ada@example.com",
            UserName = "ada",
            ProfileCvText = "Existing master CV text",
            ProfileCvStructureJson = "{\"jobs\":[]}",
        };
        var controller = BuildProfileController(user);

        var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, "Ada", "Lovelace", "Ada L.", null, null));

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("Ada", user.FirstName);
        Assert.Equal("Existing master CV text", user.ProfileCvText);
        Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson);
    }

    [Fact]
    public async Task Career_only_save_does_not_wipe_identity()
    {
        // The mirror: /career saves the master profile and omits identity fields.
        var user = new ApplicationUser
        {
            Email = "ada@example.com",
            UserName = "ada",
            FirstName = "Ada",
            LastName = "Lovelace",
            DisplayName = "Ada L.",
        };
        var controller = BuildProfileController(user);

        var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}"));

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("Ada", user.FirstName);
        Assert.Equal("Lovelace", user.LastName);
        Assert.Equal("Ada L.", user.DisplayName);
        Assert.Equal("New CV text", user.ProfileCvText);
        Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson);
    }

    [Fact]
    public async Task Empty_string_clears_a_field_but_null_leaves_it()
    {
        var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", DisplayName = "Ada L.", FirstName = "Ada" };
        var controller = BuildProfileController(user);

        // DisplayName "" -> cleared; FirstName null -> unchanged.
        var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, "", null, null));

        Assert.IsType<NoContentResult>(result);
        Assert.Null(user.DisplayName);
        Assert.Equal("Ada", user.FirstName);
    }

    [Fact]
    public async Task Email_and_username_are_never_cleared_to_empty()
    {
        var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada" };
        var controller = BuildProfileController(user);

        var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest("", "", null, null, null, null, null));

        Assert.IsType<NoContentResult>(result);
        Assert.Equal("ada@example.com", user.Email);
        Assert.Equal("ada", user.UserName);
    }

    [Fact]
    public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails()
    {
        var user = new ApplicationUser { Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = true };
        var userManager = CreateUserManager();
        userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
        userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
        userManager.Setup(x => x.GeneratePasswordResetTokenAsync(user)).ReturnsAsync("reset-token");

        var emailSender = new Mock<IAppEmailSender>();
        emailSender
            .Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
            .ThrowsAsync(new InvalidOperationException("SMTP unavailable"));

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext
            {
                HttpContext = new DefaultHttpContext()
            }
        };
        controller.Request.Scheme = "https";
        controller.Request.Host = new HostString("jobtracker.test");

        var result = await controller.RequestPasswordReset(new AuthController.RequestPasswordResetRequest("person@example.com"), CancellationToken.None);

        var problem = Assert.IsType<ObjectResult>(result);
        Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode);
        var details = Assert.IsType<ProblemDetails>(problem.Value);
        Assert.Equal("Email delivery unavailable", details.Title);
    }

    [Fact]
    public async Task Exchange_google_token_auto_links_single_verified_email_match()
    {
        var user = new ApplicationUser
        {
            Id = "user-1",
            Email = "dj@cesnimda.co.uk",
            UserName = "dj@cesnimda.co.uk",
        };

        var userManager = CreateUserManager();
        userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
        userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user);
        userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var googleValidator = new Mock<IGoogleTokenValidator>();
        googleValidator
            .Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));

        var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext
            {
                HttpContext = new DefaultHttpContext()
            }
        };

        var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);

        var ok = Assert.IsType<OkObjectResult>(result);
        var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
        Assert.True(payload.Authenticated);
        Assert.Equal("google", payload.Provider);
        Assert.Equal("google-subject", user.GoogleSubject);
        Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail);
        Assert.NotNull(user.GoogleLinkedAt);
    }

    [Fact]
    public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var userManager = CreateUserManager();
        userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
        userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
        ApplicationUser? created = null;
        userManager
            .Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
            .Callback<ApplicationUser>(u => created = u)
            .ReturnsAsync(IdentityResult.Success);
        userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);

        var tokenService = new Mock<ITokenService>();
        tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");

        var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
        microsoftValidator
            .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "new.hire@example.com", false, "New", "Hire", "New Hire", tenantId, objectId));

        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
            .Build();

        var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext
            {
                HttpContext = new DefaultHttpContext()
            }
        };

        var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);

        var ok = Assert.IsType<OkObjectResult>(result);
        var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
        Assert.True(payload.Authenticated);
        Assert.Equal("microsoft", payload.Provider);
        Assert.NotNull(created);
        Assert.Equal("new.hire@example.com", created!.Email);
        Assert.False(created.EmailConfirmed);
        Assert.Equal(tenantId, created.MicrosoftTenantId);
        Assert.Equal(objectId, created.MicrosoftObjectId);
        Assert.Null(created.MicrosoftSubject);
    }

    [Fact]
    public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var userManager = CreateUserManager();
        userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
        userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);

        var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
        microsoftValidator
            .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "nobody@example.com", false, null, null, null, tenantId, objectId));

        var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext
            {
                HttpContext = new DefaultHttpContext()
            }
        };

        var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);

        Assert.IsType<UnauthorizedObjectResult>(result);
        userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
    }

    [Fact]
    public async Task Exchange_microsoft_token_never_auto_links_an_existing_email()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var existing = new ApplicationUser { Id = "local-1", Email = "same@example.com", UserName = "same@example.com", EmailConfirmed = true };
        var users = CreateUserManager();
        users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
        users.Setup(x => x.FindByEmailAsync("same@example.com")).ReturnsAsync(existing);
        var microsoft = new Mock<IMicrosoftTokenValidator>();
        microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "same@example.com", false, null, null, null, tenantId, objectId));
        var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" }).Build();
        var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);

        Assert.IsType<ConflictObjectResult>(result);
        Assert.Null(existing.MicrosoftTenantId);
        users.Verify(x => x.UpdateAsync(existing), Times.Never);
        users.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
    }

    [Fact]
    public async Task Exchange_microsoft_token_uses_the_full_tenant_object_pair()
    {
        const string tenantA = "11111111-1111-1111-1111-111111111111";
        const string tenantB = "33333333-3333-3333-3333-333333333333";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var userA = new ApplicationUser { Id = "user-a", Email = "a@example.com", UserName = "a", EmailConfirmed = true, MicrosoftTenantId = tenantA, MicrosoftObjectId = objectId };
        var userB = new ApplicationUser { Id = "user-b", Email = "b@example.com", UserName = "b", EmailConfirmed = true, MicrosoftTenantId = tenantB, MicrosoftObjectId = objectId };
        var users = CreateUserManager();
        users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new[] { userA, userB }));
        users.Setup(x => x.UpdateAsync(userB)).ReturnsAsync(IdentityResult.Success);
        var tokens = new Mock<ITokenService>();
        tokens.Setup(x => x.CreateAccessTokenAsync(userB, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
        var microsoft = new Mock<IMicrosoftTokenValidator>();
        microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "shared@example.com", false, null, null, null, tenantB, objectId));
        var controller = new AuthController(BuildConfig(), users.Object, tokens.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);

        Assert.IsType<OkObjectResult>(result);
        tokens.Verify(x => x.CreateAccessTokenAsync(userB, It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);
        tokens.Verify(x => x.CreateAccessTokenAsync(userA, It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task Exchange_legacy_microsoft_link_sends_proof_but_does_not_sign_in()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var legacy = new ApplicationUser { Id = "legacy-1", Email = "legacy@example.com", EmailConfirmed = true, MicrosoftSubject = objectId, MicrosoftEmail = "legacy@example.com" };
        var users = CreateUserManager();
        users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new[] { legacy }));
        users.Setup(x => x.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, It.Is<string>(p => p.Contains(tenantId) && p.Contains(objectId)))).ReturnsAsync("recovery-token");
        var microsoft = new Mock<IMicrosoftTokenValidator>();
        microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "legacy@example.com", false, null, null, null, tenantId, objectId));
        var email = new Mock<IAppEmailSender>();
        var appTokens = new Mock<ITokenService>();
        var controller = new AuthController(BuildConfig(), users.Object, appTokens.Object, email.Object, Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);

        Assert.IsType<AcceptedResult>(result);
        Assert.Null(legacy.MicrosoftTenantId);
        appTokens.Verify(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
        email.Verify(x => x.SendAsync("legacy@example.com", It.IsAny<string>(), It.Is<string>(body => body.Contains("microsoft-legacy-relink")), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Link_microsoft_requires_current_password_and_uses_canonical_pair_only()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var user = new ApplicationUser { Id = "user-1", Email = "local@example.com", EmailConfirmed = true };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
        users.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
        users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(Array.Empty<ApplicationUser>()));
        users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
        var microsoft = new Mock<IMicrosoftTokenValidator>();
        microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId));
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.LinkMicrosoft(new AuthController.MicrosoftTokenRequest("token", CurrentPassword: "correct-password"), CancellationToken.None);

        Assert.IsType<OkObjectResult>(result.Result);
        Assert.Equal(tenantId, user.MicrosoftTenantId);
        Assert.Equal(objectId, user.MicrosoftObjectId);
        Assert.Null(user.MicrosoftSubject);
    }

    [Fact]
    public async Task Unlink_microsoft_refuses_a_passwordless_last_credential()
    {
        var user = new ApplicationUser { Id = "user-1", MicrosoftTenantId = "11111111-1111-1111-1111-111111111111", MicrosoftObjectId = "22222222-2222-2222-2222-222222222222" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(false);
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());

        var result = await controller.UnlinkMicrosoft(new AuthController.MicrosoftUnlinkRequest("irrelevant"), CancellationToken.None);

        Assert.IsType<BadRequestObjectResult>(result);
        Assert.NotNull(user.MicrosoftTenantId);
        users.Verify(x => x.UpdateAsync(user), Times.Never);
    }

    [Fact]
    public async Task Confirm_legacy_relink_requires_matching_microsoft_proof()
    {
        const string tenantId = "11111111-1111-1111-1111-111111111111";
        const string objectId = "22222222-2222-2222-2222-222222222222";
        var user = new ApplicationUser { Id = "legacy-1", Email = "legacy@example.com", EmailConfirmed = true, MicrosoftSubject = objectId };
        var users = CreateUserManager();
        users.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
        users.Setup(x => x.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, It.Is<string>(p => p.Contains(tenantId) && p.Contains(objectId)), "recovery-token")).ReturnsAsync(true);
        users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new[] { user }));
        users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
        var microsoft = new Mock<IMicrosoftTokenValidator>();
        microsoft.Setup(x => x.ValidateAsync("fresh-token", It.IsAny<CancellationToken>()))
            .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId));
        var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var result = await controller.ConfirmMicrosoftLegacyRelink(new AuthController.ConfirmMicrosoftLegacyRelinkRequest(user.Id, tenantId, objectId, "recovery-token", "fresh-token"), CancellationToken.None);

        Assert.IsType<NoContentResult>(result);
        Assert.Equal(tenantId, user.MicrosoftTenantId);
        Assert.Equal(objectId, user.MicrosoftObjectId);
        Assert.Equal("metadata@example.com", user.MicrosoftEmail);
    }

    [Fact]
    public void Me_result_includes_google_link_details_for_local_users()
    {
        var method = typeof(AuthController).GetMethod("ToMeResult", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
        Assert.NotNull(method);

        var user = new ApplicationUser
        {
            Id = "user-1",
            Email = "person@example.com",
            UserName = "person",
            FirstName = "Ada",
            LastName = "Lovelace",
            DisplayName = "Ada Lovelace",
            GoogleSubject = "sub-123",
            GoogleEmail = "person@example.com",
            GoogleLinkedAt = DateTimeOffset.UtcNow,
        };

        var result = (AuthController.MeResult)method!.Invoke(null, new object[] { user, new List<string> { "Admin" } })!;

        Assert.Equal("local", result.Provider);
        Assert.Equal("Ada", result.FirstName);
        Assert.Equal("Lovelace", result.LastName);
        Assert.Equal("Ada Lovelace", result.DisplayName);
        Assert.NotNull(result.GoogleLink);
        Assert.True(result.GoogleLink!.Linked);
        Assert.Equal("person@example.com", result.GoogleLink.Email);
    }

    [Fact]
    public async Task Me_result_includes_configured_build_metadata()
    {
        var user = new ApplicationUser { Id = "admin-1", Email = "admin@example.com", UserName = "admin" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Admin" });
        var cfg = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["App:Version"] = "2.4.1",
                ["App:CommitSha"] = "abc1234",
            })
            .Build();
        var controller = new AuthController(cfg, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var response = await controller.Me(CancellationToken.None);

        var ok = Assert.IsType<OkObjectResult>(response);
        var result = Assert.IsType<AuthController.MeResult>(ok.Value);
        Assert.Equal("2.4.1", result.AppVersion);
        Assert.Equal("abc1234", result.AppCommitSha);
    }

    [Fact]
    public async Task Me_result_limits_build_metadata_to_admins()
    {
        var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person" };
        var users = CreateUserManager();
        users.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
        users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string>());
        var cfg = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["App:Version"] = "2.4.1",
                ["App:CommitSha"] = "abc1234",
            })
            .Build();
        var controller = new AuthController(cfg, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        var response = await controller.Me(CancellationToken.None);

        var ok = Assert.IsType<OkObjectResult>(response);
        var result = Assert.IsType<AuthController.MeResult>(ok.Value);
        Assert.Equal("unknown", result.AppVersion);
        Assert.Null(result.AppCommitSha);
    }

    [Fact]
    public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable()
    {
        var emailSettings = new Mock<IEmailSettingsResolver>();
        emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings"));

        var cfg = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Email:Enabled"] = "false",
                ["Email:FromName"] = "Jobbjakt"
            })
            .Build();

        var controller = new AdminSystemController(
            cfg,
            new AppPaths(cfg, new FakeHostEnv()),
            null!,
            Mock.Of<ISummarizerService>(),
            new FakeEnv(),
            emailSettings.Object);

        var result = await controller.GetEmailSettings(CancellationToken.None);
        var ok = Assert.IsType<OkObjectResult>(result.Result);
        var dto = Assert.IsType<EmailSettingsAdminDto>(ok.Value);
        Assert.False(dto.Enabled);
        Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Admin_system_probe_endpoint_runs_probe_once()
    {
        var summarizer = new Mock<ISummarizerService>();
        summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);

        var controller = new AdminSystemController(
            BuildConfig(),
            new AppPaths(BuildConfig(), new FakeHostEnv()),
            null!,
            summarizer.Object,
            new FakeEnv(),
            Mock.Of<IEmailSettingsResolver>());

        var result = await controller.RunSummarizerProbe(CancellationToken.None);

        Assert.IsType<NoContentResult>(result);
        summarizer.Verify(x => x.RunProbeAsync(It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Login_rejects_missing_turnstile_token_when_configured()
    {
        var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Turnstile:SiteKey"] = "site-key",
            ["Turnstile:SecretKey"] = "secret-key",
        }).Build();
        var users = CreateUserManager();
        var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of<IHttpClientFactory>());

        var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None);

        var badRequest = Assert.IsType<BadRequestObjectResult>(result);
        Assert.Equal("Security verification failed. Please try again.", badRequest.Value);
        users.Verify(x => x.FindByEmailAsync(It.IsAny<string>()), Times.Never);
    }

    private static IConfiguration BuildConfig()
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>())
            .Build();
    }

    private static Mock<UserManager<ApplicationUser>> CreateUserManager()
    {
        return TestHostFactory.CreateUserManager();
    }

    private sealed class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
    {
        private readonly IQueryProvider _inner;

        public TestAsyncQueryProvider(IQueryProvider inner)
        {
            _inner = inner;
        }

        public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
        public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new TestAsyncEnumerable<TElement>(expression);
        public object? Execute(Expression expression) => _inner.Execute(expression);
        public TResult Execute<TResult>(Expression expression) => _inner.Execute<TResult>(expression);
        public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken = default)
            => (TResult)typeof(Task)
                .GetMethod(nameof(Task.FromResult))!
                .MakeGenericMethod(typeof(TResult).GetGenericArguments()[0])
                .Invoke(null, new[] { Execute(expression) })!;
    }

    private sealed class TestAsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
    {
        public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
        public TestAsyncEnumerable(Expression expression) : base(expression) { }

        public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
            => new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());

        IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
    }

    private sealed class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
    {
        private readonly IEnumerator<T> _inner;

        public TestAsyncEnumerator(IEnumerator<T> inner)
        {
            _inner = inner;
        }

        public T Current => _inner.Current;

        public ValueTask DisposeAsync()
        {
            _inner.Dispose();
            return ValueTask.CompletedTask;
        }

        public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext());
    }

    private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment
    {
        public string EnvironmentName { get; set; } = "Test";
        public string ApplicationName { get; set; } = "JobTrackerApi";
        public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
        public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
    }

    private sealed class FakeEnv : Microsoft.AspNetCore.Hosting.IWebHostEnvironment
    {
        public string ApplicationName { get; set; } = "JobTrackerApi";
        public Microsoft.Extensions.FileProviders.IFileProvider WebRootFileProvider { get; set; } = null!;
        public string WebRootPath { get; set; } = string.Empty;
        public string EnvironmentName { get; set; } = "Test";
        public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
        public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
    }
}
