using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;

namespace JobTrackerApi.Tests;

public sealed class AccountDeletionTests
{
    [Fact]
    public async Task Disabled_lifecycle_never_accepts_a_deletion_request()
    {
        await InFixtureAsync(enabled: false, async fixture =>
        {
            fixture.Db.Users.Add(User("owner"));
            await fixture.Db.SaveChangesAsync();

            Assert.Null(await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None));
            Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
            Assert.Empty(await fixture.Db.AccountDeletionRequests.ToListAsync());
        });
    }

    [Fact]
    public async Task Request_is_idempotent_and_immediately_locks_down_the_account()
    {
        await InFixtureAsync(enabled: true, async fixture =>
        {
            var now = DateTimeOffset.UtcNow;
            fixture.Db.Users.AddRange(User("owner"), User("other"));
            fixture.Db.CvVariants.Add(new CvVariant
            {
                OwnerUserId = "owner", PublicSlug = "public-owner", Name = "Owner CV", IsPublic = true,
                CreatedAtUtc = now, UpdatedAtUtc = now,
            });
            fixture.Db.UserSessions.Add(new UserSession
            {
                Id = "session", UserId = "owner", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
            });
            fixture.Db.TrustedDevices.Add(new TrustedDevice
            {
                UserId = "owner", TokenHash = "hash", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddDays(1),
            });
            var queued = Operation("owner", OperationStatuses.Queued);
            var running = Operation("owner", OperationStatuses.Running);
            fixture.Db.UserOperations.AddRange(queued, running);
            await fixture.Db.SaveChangesAsync();

            var first = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
            var second = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);

            Assert.NotNull(first);
            Assert.Equal(first!.RequestId, second!.RequestId);
            Assert.Single(await fixture.Db.AccountDeletionRequests.ToListAsync());
            var user = await fixture.Db.Users.SingleAsync(item => item.Id == "owner");
            Assert.Equal(AccountDeletionStatuses.Pending, user.DeletionStatus);
            Assert.NotNull(user.DeletionRequestedAtUtc);
            Assert.False((await fixture.Db.CvVariants.IgnoreQueryFilters().SingleAsync()).IsPublic);
            Assert.NotNull((await fixture.Db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc);
            Assert.Empty(await fixture.Db.TrustedDevices.IgnoreQueryFilters().ToListAsync());
            Assert.Equal(OperationStatuses.Cancelled, (await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == queued.Id)).Status);
            Assert.NotNull((await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == running.Id)).CancellationRequestedAtUtc);
            Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync(item => item.Id == "other")).DeletionStatus);
        });
    }

    [Fact]
    public async Task Completed_deletion_is_owner_isolated_purges_files_and_replays_after_restore()
    {
        await InFixtureAsync(enabled: true, async fixture =>
        {
            var owner = User("owner");
            var other = User("other");
            fixture.Db.Users.AddRange(owner, other);
            var ownerCompany = new Company { OwnerUserId = owner.Id, Name = "Owner company" };
            var otherCompany = new Company { OwnerUserId = other.Id, Name = "Other company" };
            fixture.Db.Companies.AddRange(ownerCompany, otherCompany);
            await fixture.Db.SaveChangesAsync();
            var ownerApplication = new JobApplication { OwnerUserId = owner.Id, CompanyId = ownerCompany.Id, JobTitle = "Owner role" };
            var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = otherCompany.Id, JobTitle = "Other role" };
            fixture.Db.JobApplications.AddRange(ownerApplication, otherApplication);
            await fixture.Db.SaveChangesAsync();

            var attachmentPath = Path.Combine(fixture.Paths.AttachmentsRoot, ownerApplication.Id.ToString(), "owner.txt");
            Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
            await File.WriteAllTextAsync(attachmentPath, "owner attachment");
            fixture.Db.Attachments.Add(new Attachment
            {
                JobApplicationId = ownerApplication.Id, FileName = "owner.txt", FilePath = attachmentPath,
                FileType = "text/plain", FileSize = new FileInfo(attachmentPath).Length,
            });
            var artifactRoot = Path.Combine(fixture.Paths.CvArtifactsRoot, AppPaths.GetOwnerStorageKey(owner.Id));
            Directory.CreateDirectory(artifactRoot);
            var artifactPath = Path.Combine(artifactRoot, "owner-cv.txt");
            await File.WriteAllTextAsync(artifactPath, "owner cv");
            fixture.Db.CvUploadArtifacts.Add(new CvUploadArtifact
            {
                OwnerUserId = owner.Id, OriginalFileName = "owner-cv.txt", StoredFileName = "owner-cv.txt",
                MimeType = "text/plain", ByteSize = new FileInfo(artifactPath).Length, Sha256 = "synthetic", StoragePath = artifactPath,
            });
            var otherOperation = Operation(other.Id, OperationStatuses.Queued);
            fixture.Db.UserOperations.Add(otherOperation);
            fixture.Db.AiUsageRecords.AddRange(
                new AiUsageRecord
                {
                    OwnerUserId = owner.Id, SourceType = "operation", SourceId = "owner-usage",
                    TaskType = "synthetic", EstimatedTokenCount = 100, CreatedAtUtc = DateTimeOffset.UtcNow,
                },
                new AiUsageRecord
                {
                    OwnerUserId = other.Id, SourceType = "operation", SourceId = "other-usage",
                    TaskType = "synthetic", EstimatedTokenCount = 100, CreatedAtUtc = DateTimeOffset.UtcNow,
                });
            await fixture.Db.SaveChangesAsync();
            var generatedRoot = Path.Combine(fixture.Paths.GetOwnerCvExportsRoot(owner.Id), "20260815");
            Directory.CreateDirectory(generatedRoot);
            var generatedPath = Path.Combine(generatedRoot, "owner.pdf");
            await File.WriteAllTextAsync(generatedPath, "owner pdf");
            var accountExportRoot = fixture.Paths.GetOwnerAccountExportsRoot(owner.Id);
            Directory.CreateDirectory(accountExportRoot);
            var accountExportPath = Path.Combine(accountExportRoot, "previous.zip");
            await File.WriteAllTextAsync(accountExportPath, "previous account export");

            var accepted = await fixture.Service.RequestAsync(owner.Id, owner.Id, CancellationToken.None);
            Assert.NotNull(accepted);
            Assert.True(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None));

            Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
            Assert.True(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == other.Id));
            Assert.False(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
            Assert.True(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
            Assert.False(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
            Assert.True(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
            Assert.True(await fixture.Db.UserOperations.IgnoreQueryFilters().AnyAsync(item => item.Id == otherOperation.Id));
            Assert.False(await fixture.Db.AiUsageRecords.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
            Assert.True(await fixture.Db.AiUsageRecords.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
            Assert.False(File.Exists(attachmentPath));
            Assert.False(File.Exists(artifactPath));
            Assert.False(File.Exists(generatedPath));
            Assert.False(File.Exists(accountExportPath));
            var request = await fixture.Db.AccountDeletionRequests.Include(item => item.Files).SingleAsync(item => item.Id == accepted.RequestId);
            Assert.Equal(AccountDeletionRequestStatuses.Completed, request.Status);
            Assert.Equal(AccountDeletionStages.Completed, request.Stage);
            Assert.NotEmpty(request.Files);
            Assert.All(request.Files, item => Assert.Equal("purged", item.Status));
            Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None));
            Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None));

            fixture.Db.Users.Add(User(owner.Id));
            await fixture.Db.SaveChangesAsync();
            Assert.Equal(1, await fixture.Service.StageRestoredAccountsAsync(CancellationToken.None));
            fixture.Db.ChangeTracker.Clear();
            Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.AsNoTracking().SingleAsync(item => item.Id == owner.Id)).DeletionStatus);
            Assert.Equal(1, await fixture.Service.ProcessPendingAsync(CancellationToken.None));
            Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
            Assert.Equal(2, (await fixture.Tombstones.ReadAsync(CancellationToken.None)).Count);
            fixture.CachePurger.Verify(item => item.PurgeAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
        });
    }

    [Fact]
    public async Task Sidecar_cache_failure_keeps_deleted_account_retryable_until_purge_succeeds()
    {
        await InFixtureAsync(enabled: true, async fixture =>
        {
            fixture.Db.Users.Add(User("owner"));
            await fixture.Db.SaveChangesAsync();
            fixture.CachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>()))
                .ThrowsAsync(new HttpRequestException("synthetic sidecar outage"));

            var accepted = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
            Assert.NotNull(accepted);
            Assert.False(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None));

            fixture.Db.ChangeTracker.Clear();
            Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == "owner"));
            var retry = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == accepted.RequestId);
            Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, retry.Status);
            Assert.Equal(AccountDeletionStages.PurgingFiles, retry.Stage);
            Assert.Empty(await fixture.Tombstones.ReadAsync(CancellationToken.None));

            fixture.CachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>()))
                .Returns(Task.CompletedTask);
            Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None));
            Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None));
        });
    }

    [Fact]
    public async Task Quarantine_failure_restores_files_and_never_starts_database_deletion()
    {
        await InFixtureAsync(enabled: true, async fixture =>
        {
            fixture.Db.Users.Add(User("owner"));
            var source = Path.Combine(fixture.Paths.AttachmentsRoot, "source.txt");
            await File.WriteAllTextAsync(source, "must survive");
            var invalidTarget = source + ".quarantine";
            Directory.CreateDirectory(invalidTarget);
            var request = new AccountDeletionRequest
            {
                Id = Guid.NewGuid(), OwnerUserId = "owner", OwnerKey = AppPaths.GetOwnerStorageKey("owner"),
                RequestedByUserId = "owner", Status = AccountDeletionRequestStatuses.Pending,
                Stage = AccountDeletionStages.QuarantiningFiles, RequestedAtUtc = DateTimeOffset.UtcNow,
                Files =
                [
                    new AccountDeletionFile
                    {
                        Category = "attachment", OriginalPath = source, QuarantinePath = invalidTarget,
                        Status = "planned", ByteSize = new FileInfo(source).Length, Sha256 = "synthetic",
                    },
                ],
            };
            fixture.Db.AccountDeletionRequests.Add(request);
            await fixture.Db.SaveChangesAsync();

            Assert.False(await fixture.Service.ProcessAsync(request.Id, CancellationToken.None));
            fixture.Db.ChangeTracker.Clear();
            var failed = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == request.Id);
            Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, failed.Status);
            Assert.Equal(AccountDeletionStages.QuarantiningFiles, failed.Stage);
            Assert.True(File.Exists(source));
            Assert.True(await fixture.Db.Users.AnyAsync(item => item.Id == "owner"));
        });
    }

    [Fact]
    public async Task Self_service_requires_exact_phrase_and_recent_sign_in()
    {
        await InFixtureAsync(enabled: true, async fixture =>
        {
            var now = DateTimeOffset.UtcNow;
            fixture.Db.Users.Add(User("owner"));
            fixture.Db.UserSessions.Add(new UserSession
            {
                Id = "session", UserId = "owner", CreatedAtUtc = now.AddMinutes(-16),
                LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
            });
            await fixture.Db.SaveChangesAsync();
            var controller = Controller(fixture, "owner", "session");

            Assert.IsType<BadRequestObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE wrong@example.test"), CancellationToken.None));
            var stale = Assert.IsType<ObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
            Assert.Equal(StatusCodes.Status403Forbidden, stale.StatusCode);

            var session = await fixture.Db.UserSessions.SingleAsync();
            session.CreatedAtUtc = DateTimeOffset.UtcNow;
            await fixture.Db.SaveChangesAsync();
            var accepted = Assert.IsType<AcceptedResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
            Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode);
            Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
        });
    }

    private static ApplicationUser User(string id) => new()
    {
        Id = id, UserName = $"{id}@example.test", NormalizedUserName = $"{id.ToUpperInvariant()}@EXAMPLE.TEST",
        Email = $"{id}@example.test", NormalizedEmail = $"{id.ToUpperInvariant()}@EXAMPLE.TEST", EmailConfirmed = true,
    };

    private static UserOperation Operation(string owner, string status) => new()
    {
        Id = Guid.NewGuid(), OwnerUserId = owner, TaskType = "synthetic", IdempotencyKey = Guid.NewGuid().ToString("N"),
        Status = status, CreatedAtUtc = DateTime.UtcNow, AvailableAtUtc = DateTime.UtcNow,
    };

    private static AccountLifecycleController Controller(Fixture fixture, string userId, string sessionId)
    {
        var controller = new AccountLifecycleController(fixture.Db, fixture.Service, TimeProvider.System)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
        };
        controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
        [
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim("sid", sessionId),
        ], "local"));
        return controller;
    }

    private static async Task InFixtureAsync(bool enabled, Func<Fixture, Task> test)
    {
        var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-deletion-{Guid.NewGuid():N}");
        Directory.CreateDirectory(root);
        try
        {
            var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Data:Root"] = root,
                ["AccountLifecycle:DeletionEnabled"] = enabled.ToString(),
            }).Build();
            var environment = new Mock<IHostEnvironment>();
            environment.SetupGet(item => item.ContentRootPath).Returns(root);
            var paths = new AppPaths(configuration, environment.Object);
            var currentUser = new Mock<ICurrentUserService>();
            currentUser.SetupGet(item => item.UserId).Returns("owner");
            var options = new DbContextOptionsBuilder<JobTrackerContext>()
                .UseSqlite($"Data Source={Path.Combine(root, "deletion-tests.db")}")
                .Options;
            await using var db = new JobTrackerContext(options, currentUser.Object);
            await db.Database.EnsureCreatedAsync();
            using var cache = new MemoryCache(new MemoryCacheOptions());
            var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths));
            var tombstones = new AccountDeletionTombstoneStore(paths);
            var cachePurger = new Mock<IAiSidecarCachePurger>();
            cachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
            var service = new AccountDeletionService(db, inventory, tombstones, cachePurger.Object, configuration, cache, TimeProvider.System, NullLogger<AccountDeletionService>.Instance);
            await test(new Fixture(db, paths, tombstones, cachePurger, service));
        }
        finally
        {
            SqliteConnection.ClearAllPools();
            if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
        }
    }

    private sealed record Fixture(
        JobTrackerContext Db,
        AppPaths Paths,
        AccountDeletionTombstoneStore Tombstones,
        Mock<IAiSidecarCachePurger> CachePurger,
        AccountDeletionService Service);
}
