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.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Moq;
using Xunit;

namespace JobTrackerApi.Tests;

public sealed class AttachmentConsistencyTests
{
    [Fact]
    public async Task Invalid_later_file_writes_no_rows_or_bytes()
    {
        await using var fixture = await Fixture.CreateAsync();
        var job = await fixture.SeedJobAsync();
        var files = Files(File("resume.pdf", "ok"), File("payload.exe", "bad"));

        var result = await fixture.Controller.Upload(files, job.Id, default);

        Assert.IsType<BadRequestObjectResult>(result);
        Assert.Empty(await fixture.Db.Attachments.ToListAsync());
        Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories));
    }

    [Fact]
    public async Task Cancelled_copy_cleans_staging_and_writes_no_rows()
    {
        await using var fixture = await Fixture.CreateAsync();
        var job = await fixture.SeedJobAsync();
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Controller.Upload(Files(File("resume.pdf", "content")), job.Id, cancellation.Token));

        Assert.Empty(await fixture.Db.Attachments.ToListAsync());
        Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories));
    }

    [Fact]
    public async Task Committed_upload_with_failed_promotion_is_recovered_on_restart_pass()
    {
        await using var fixture = await Fixture.CreateAsync();
        var job = await fixture.SeedJobAsync();
        var failing = new FailingStorage(fixture.Storage) { FailPromote = true };
        var controller = fixture.CreateController(failing);

        Assert.IsType<AcceptedResult>(await controller.Upload(Files(File("resume.pdf", "content")), job.Id, default));
        var row = await fixture.Db.Attachments.SingleAsync();
        Assert.False(System.IO.File.Exists(row.FilePath));
        Assert.True(System.IO.File.Exists(fixture.Storage.StagePath(row.FilePath)));

        var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default);

        Assert.Equal(1, recovered.Promoted);
        Assert.True(System.IO.File.Exists(row.FilePath));
        Assert.False(System.IO.File.Exists(fixture.Storage.StagePath(row.FilePath)));

        var repeated = await fixture.Storage.ReconcileAsync(fixture.Db, default);
        Assert.Equal(new AttachmentReconciliationResult(0, 0, 0, 0, 0, 0, 0), repeated);
    }

    [Fact]
    public async Task Rename_changes_metadata_without_moving_bytes()
    {
        await using var fixture = await Fixture.CreateAsync();
        var (job, row) = await fixture.SeedAttachmentAsync("original.pdf", "resume");
        var originalPath = row.FilePath;

        Assert.IsType<NoContentResult>(await fixture.Controller.Rename(row.Id, new AttachmentsController.UpdateAttachmentRequest("renamed.pdf", "portfolio", null), default));

        Assert.Equal("renamed.pdf", row.FileName);
        Assert.Equal(originalPath, row.FilePath);
        Assert.True(System.IO.File.Exists(originalPath));
        Assert.True((await fixture.Db.JobApplications.FindAsync(job.Id))!.HasPortfolio);
    }

    [Fact]
    public async Task Failed_delete_purge_leaves_retryable_trash_and_reconciler_purges_it()
    {
        await using var fixture = await Fixture.CreateAsync();
        var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume");
        var deletePath = fixture.Storage.DeletePath(row.FilePath);
        var failing = new FailingStorage(fixture.Storage) { FailDeletePurge = true };

        Assert.IsType<AcceptedResult>(await fixture.CreateController(failing).Delete(row.Id, default));
        Assert.Empty(await fixture.Db.Attachments.ToListAsync());
        Assert.True(System.IO.File.Exists(deletePath));

        var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default);

        Assert.Equal(1, recovered.Purged);
        Assert.False(System.IO.File.Exists(deletePath));
    }

    [Fact]
    public async Task Restart_restores_quarantined_file_when_database_row_still_exists()
    {
        await using var fixture = await Fixture.CreateAsync();
        var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume");
        var deletePath = fixture.Storage.DeletePath(row.FilePath);
        fixture.Storage.Quarantine(row.FilePath, deletePath);

        var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default);

        Assert.Equal(1, recovered.Restored);
        Assert.True(System.IO.File.Exists(row.FilePath));
        Assert.False(System.IO.File.Exists(deletePath));
    }

    [Fact]
    public async Task Unknown_legacy_orphan_is_reported_and_preserved()
    {
        await using var fixture = await Fixture.CreateAsync();
        var folder = Path.Combine(fixture.Paths.AttachmentsRoot, "legacy");
        Directory.CreateDirectory(folder);
        var orphan = Path.Combine(folder, "unknown.pdf");
        await System.IO.File.WriteAllTextAsync(orphan, "unknown");

        var result = await fixture.Storage.ReconcileAsync(fixture.Db, default);

        Assert.Equal(1, result.UnknownOrphans);
        Assert.True(System.IO.File.Exists(orphan));
    }

    [Fact]
    public async Task Database_failure_during_upload_removes_all_staged_bytes()
    {
        var failure = new SaveFailureInterceptor();
        await using var fixture = await Fixture.CreateAsync(failure);
        var job = await fixture.SeedJobAsync();
        failure.FailOnSavingCall = 1;

        await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Controller.Upload(Files(File("resume.pdf", "content")), job.Id, default));

        fixture.Db.ChangeTracker.Clear();
        Assert.Empty(await fixture.Db.Attachments.AsNoTracking().ToListAsync());
        Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories));
    }

    [Fact]
    public async Task Database_failure_during_delete_restores_quarantined_bytes_and_row()
    {
        var failure = new SaveFailureInterceptor();
        await using var fixture = await Fixture.CreateAsync(failure);
        var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume");
        var path = row.FilePath;
        failure.FailOnSavingCall = 1;

        await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Controller.Delete(row.Id, default));

        fixture.Db.ChangeTracker.Clear();
        Assert.Single(await fixture.Db.Attachments.AsNoTracking().ToListAsync());
        Assert.True(System.IO.File.Exists(path));
        Assert.False(System.IO.File.Exists(fixture.Storage.DeletePath(path)));
    }

    [Fact]
    public async Task Purpose_and_derived_flags_roll_back_together()
    {
        var failure = new SaveFailureInterceptor();
        await using var fixture = await Fixture.CreateAsync(failure);
        var (job, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume");
        failure.FailOnSavingCall = 2;

        await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Controller.Rename(
            row.Id,
            new AttachmentsController.UpdateAttachmentRequest(null, "portfolio", null),
            default));

        fixture.Db.ChangeTracker.Clear();
        Assert.Equal("resume", (await fixture.Db.Attachments.AsNoTracking().SingleAsync()).Purpose);
        var storedJob = await fixture.Db.JobApplications.AsNoTracking().SingleAsync(x => x.Id == job.Id);
        Assert.True(storedJob.HasResume);
        Assert.False(storedJob.HasPortfolio);
    }

    [Fact]
    public async Task Storage_rejects_paths_outside_root()
    {
        await using var fixture = await Fixture.CreateAsync();
        Assert.False(fixture.Storage.IsManagedPath(Path.Combine(Path.GetTempPath(), $"outside-{Guid.NewGuid():N}.pdf")));
    }

    [Fact]
    public async Task Repeated_same_name_uploads_use_distinct_storage_paths()
    {
        await using var fixture = await Fixture.CreateAsync();
        var job = await fixture.SeedJobAsync();

        Assert.IsType<OkResult>(await fixture.Controller.Upload(Files(File("resume.pdf", "first")), job.Id, default));
        Assert.IsType<OkResult>(await fixture.Controller.Upload(Files(File("resume.pdf", "second")), job.Id, default));

        var rows = await fixture.Db.Attachments.AsNoTracking().ToListAsync();
        Assert.Equal(2, rows.Count);
        Assert.Equal(2, rows.Select(x => x.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count());
        Assert.All(rows, row => Assert.True(System.IO.File.Exists(row.FilePath)));
    }

    [Fact]
    public async Task Exact_size_limit_is_accepted_and_one_byte_over_is_rejected()
    {
        await using var fixture = await Fixture.CreateAsync();
        var job = await fixture.SeedJobAsync();
        const int limit = 10 * 1024 * 1024;

        Assert.IsType<OkResult>(await fixture.Controller.Upload(Files(FileOfLength("limit.pdf", limit)), job.Id, default));
        Assert.IsType<BadRequestObjectResult>(await fixture.Controller.Upload(Files(FileOfLength("too-large.pdf", limit + 1)), job.Id, default));

        var row = await fixture.Db.Attachments.AsNoTracking().SingleAsync();
        Assert.Equal(limit, row.FileSize);
        Assert.Equal(limit, new FileInfo(row.FilePath).Length);
    }

    [Fact]
    public async Task Another_users_attachment_cannot_be_downloaded_renamed_or_deleted()
    {
        await using var fixture = await Fixture.CreateAsync();
        var company = new Company { OwnerUserId = "user-2", Name = "Other" };
        fixture.Db.Companies.Add(company);
        await fixture.Db.SaveChangesAsync();
        var job = new JobApplication { OwnerUserId = "user-2", CompanyId = company.Id, JobTitle = "Other job", Status = "Applied" };
        fixture.Db.JobApplications.Add(job);
        await fixture.Db.SaveChangesAsync();
        var path = fixture.Storage.CreateFinalPath(job.Id, $"other-{Guid.NewGuid():N}.pdf");
        await System.IO.File.WriteAllTextAsync(path, "other user");
        var row = new Attachment { JobApplicationId = job.Id, FileName = "other.pdf", FilePath = path, FileType = "application/pdf", FileSize = 10 };
        fixture.Db.Attachments.Add(row);
        await fixture.Db.SaveChangesAsync();
        fixture.Db.ChangeTracker.Clear();

        Assert.IsType<NotFoundResult>(await fixture.Controller.Download(row.Id, default));
        Assert.IsType<NotFoundResult>(await fixture.Controller.Rename(row.Id, new AttachmentsController.UpdateAttachmentRequest("stolen.pdf", null, null), default));
        Assert.IsType<NotFoundResult>(await fixture.Controller.Delete(row.Id, default));
        Assert.True(System.IO.File.Exists(path));
        Assert.Single(await fixture.Db.Attachments.IgnoreQueryFilters().Where(x => x.Id == row.Id).ToListAsync());
    }

    private static FormFile File(string name, string content)
    {
        var bytes = System.Text.Encoding.UTF8.GetBytes(content);
        return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "files", name)
        {
            Headers = new HeaderDictionary(),
            ContentType = "application/pdf",
        };
    }

    private static FormFile FileOfLength(string name, int length)
    {
        var stream = new MemoryStream(new byte[length]);
        return new FormFile(stream, 0, length, "files", name)
        {
            Headers = new HeaderDictionary(),
            ContentType = "application/pdf",
        };
    }

    private static FormFileCollection Files(params FormFile[] files)
    {
        var result = new FormFileCollection();
        foreach (var file in files) result.Add(file);
        return result;
    }

    private sealed class FailingStorage(IAttachmentStorage inner) : IAttachmentStorage
    {
        public bool FailPromote { get; init; }
        public bool FailDeletePurge { get; init; }
        public string CreateFinalPath(int jobId, string storedFileName) => inner.CreateFinalPath(jobId, storedFileName);
        public string StagePath(string finalPath) => inner.StagePath(finalPath);
        public string DeletePath(string finalPath) => inner.DeletePath(finalPath);
        public bool IsManagedPath(string path) => inner.IsManagedPath(path);
        public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken);
        public void Promote(string stagePath, string finalPath)
        {
            if (FailPromote) throw new IOException("Synthetic promotion failure.");
            inner.Promote(stagePath, finalPath);
        }
        public void Quarantine(string finalPath, string deletePath) => inner.Quarantine(finalPath, deletePath);
        public void Restore(string deletePath, string finalPath) => inner.Restore(deletePath, finalPath);
        public void Purge(string path)
        {
            if (FailDeletePurge && path.EndsWith(".deleting", StringComparison.Ordinal)) throw new IOException("Synthetic purge failure.");
            inner.Purge(path);
        }
        public Task<AttachmentReconciliationResult> ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken) => inner.ReconcileAsync(db, cancellationToken);
    }

    private sealed class SaveFailureInterceptor : SaveChangesInterceptor
    {
        public int FailOnSavingCall { get; set; }

        public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
            DbContextEventData eventData,
            InterceptionResult<int> result,
            CancellationToken cancellationToken = default)
        {
            if (FailOnSavingCall > 0 && --FailOnSavingCall == 0)
                throw new InvalidOperationException("Synthetic database failure.");
            return base.SavingChangesAsync(eventData, result, cancellationToken);
        }
    }

    private sealed class Fixture : IAsyncDisposable
    {
        private readonly SqliteConnection _connection;
        private readonly string _root;
        public JobTrackerContext Db { get; }
        public AppPaths Paths { get; }
        public AttachmentStorage Storage { get; }
        public AttachmentsController Controller { get; }

        private Fixture(SqliteConnection connection, string root, JobTrackerContext db, AppPaths paths, AttachmentStorage storage)
        {
            _connection = connection;
            _root = root;
            Db = db;
            Paths = paths;
            Storage = storage;
            Controller = CreateController(storage);
        }

        public static async Task<Fixture> CreateAsync(SaveChangesInterceptor? interceptor = null)
        {
            var root = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-consistency-{Guid.NewGuid():N}");
            var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build();
            var environment = new Mock<IHostEnvironment>();
            environment.SetupGet(x => x.ContentRootPath).Returns(root);
            var paths = new AppPaths(config, environment.Object);
            var connection = new SqliteConnection("Data Source=:memory:");
            await connection.OpenAsync();
            var currentUser = new Mock<ICurrentUserService>();
            currentUser.SetupGet(x => x.UserId).Returns("user-1");
            var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection);
            if (interceptor is not null) options.AddInterceptors(interceptor);
            var db = new JobTrackerContext(options.Options, currentUser.Object);
            await db.Database.EnsureCreatedAsync();
            return new Fixture(connection, root, db, paths, new AttachmentStorage(paths));
        }

        public AttachmentsController CreateController(IAttachmentStorage storage) => new(Paths, Db, storage: storage)
        {
            ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
        };

        public async Task<JobApplication> SeedJobAsync()
        {
            var company = new Company { OwnerUserId = "user-1", Name = "Acme" };
            Db.Companies.Add(company);
            await Db.SaveChangesAsync();
            var job = new JobApplication { OwnerUserId = "user-1", CompanyId = company.Id, JobTitle = "Developer", Status = "Applied" };
            Db.JobApplications.Add(job);
            await Db.SaveChangesAsync();
            return job;
        }

        public async Task<(JobApplication Job, Attachment Row)> SeedAttachmentAsync(string name, string purpose)
        {
            var job = await SeedJobAsync();
            var finalPath = Storage.CreateFinalPath(job.Id, $"seed-{Guid.NewGuid():N}.pdf");
            await System.IO.File.WriteAllTextAsync(finalPath, "synthetic");
            var row = new Attachment { JobApplicationId = job.Id, FileName = name, FilePath = finalPath, FileType = "application/pdf", FileSize = 9, Purpose = purpose };
            Db.Attachments.Add(row);
            job.HasResume = purpose == "resume";
            await Db.SaveChangesAsync();
            return (job, row);
        }

        public async ValueTask DisposeAsync()
        {
            await Db.DisposeAsync();
            await _connection.DisposeAsync();
            if (Directory.Exists(_root)) Directory.Delete(_root, true);
        }
    }
}
