using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;

namespace JobTrackerApi.Tests;

public sealed class EmailSendAttemptStoreTests
{
    [Fact]
    public async Task Creation_is_idempotent_and_rejects_payload_reuse()
    {
        await using var fixture = await Fixture.CreateAsync();
        var store = fixture.Store("user-1");
        var requestId = Guid.NewGuid().ToString();
        var first = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);
        var duplicate = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);

        Assert.True(first.Created);
        Assert.False(duplicate.Created);
        Assert.Equal(first.Attempt.Id, duplicate.Attempt.Id);
        await Assert.ThrowsAsync<EmailSendConflictException>(() =>
            store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('b')), default));
    }

    [Fact]
    public async Task State_machine_never_restarts_terminal_or_uncertain_attempts()
    {
        await using var fixture = await Fixture.CreateAsync();
        var store = fixture.Store("user-1");
        var created = await store.CreateAsync(new(fixture.JobId, "microsoft", Guid.NewGuid().ToString(), Hash('c')), default);

        Assert.Equal(1, await store.BeginAsync(created.Attempt.Id, default));
        Assert.Equal(0, await store.BeginAsync(created.Attempt.Id, default));
        Assert.Equal(1, await store.MarkUncertainAsync(created.Attempt.Id, "transport_interrupted", default));
        Assert.Equal(0, await store.MarkSentAsync(created.Attempt.Id, "late-message", default));

        var stored = await store.GetAsync(created.Attempt.Id, default);
        Assert.Equal(EmailSendStatuses.Uncertain, stored!.Status);
        Assert.Equal("transport_interrupted", stored.FailureCategory);
        Assert.NotNull(stored.CompletedAtUtc);
    }

    [Fact]
    public async Task Direct_attempt_ids_are_tenant_scoped_and_store_no_message_content()
    {
        await using var fixture = await Fixture.CreateAsync();
        var ownerStore = fixture.Store("user-1");
        var created = await ownerStore.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('d')), default);

        Assert.Null(await fixture.Store("user-2").GetAsync(created.Attempt.Id, default));
        Assert.DoesNotContain(typeof(EmailSendAttempt).GetProperties(), property =>
            property.Name.Contains("Subject", StringComparison.OrdinalIgnoreCase) ||
            property.Name.Contains("Body", StringComparison.OrdinalIgnoreCase) ||
            property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public async Task Hard_job_deletion_cascades_only_that_jobs_attempts()
    {
        await using var fixture = await Fixture.CreateAsync();
        var ownerOne = fixture.Store("user-1");
        var ownerTwo = fixture.Store("user-2");
        var mine = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('3')), default);
        var theirs = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('4')), default);

        await fixture.HardDeleteJobAsync("user-1", fixture.JobId);

        Assert.Null(await ownerOne.GetAsync(mine.Attempt.Id, default));
        Assert.NotNull(await ownerTwo.GetAsync(theirs.Attempt.Id, default));
        Assert.Equal(1, await fixture.AttemptCountIgnoringFiltersAsync());
    }

    [Fact]
    public async Task Restart_recovery_is_tenant_visible_idempotent_and_never_requeues_delivery()
    {
        await using var fixture = await Fixture.CreateAsync();
        var ownerOne = fixture.Store("user-1");
        var ownerTwo = fixture.Store("user-2");
        var oldSending = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('e')), default);
        var oldPending = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('f')), default);
        Assert.Equal(1, await ownerOne.BeginAsync(oldSending.Attempt.Id, default));

        fixture.Time.Advance(EmailSendAttemptStore.AbandonedAge + TimeSpan.FromSeconds(1));
        var freshPending = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('1')), default);
        var freshSending = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('2')), default);
        Assert.Equal(1, await ownerTwo.BeginAsync(freshSending.Attempt.Id, default));

        var recovered = await fixture.Store(null).ReconcileAbandonedAsync(default);
        var repeated = await fixture.Store(null).ReconcileAbandonedAsync(default);

        Assert.Equal(new EmailSendAttemptRecoveryResult(1, 1), recovered);
        Assert.Equal(new EmailSendAttemptRecoveryResult(0, 0), repeated);
        Assert.Equal(EmailSendStatuses.Uncertain, (await ownerOne.GetAsync(oldSending.Attempt.Id, default))!.Status);
        Assert.Equal(EmailSendStatuses.Failed, (await ownerTwo.GetAsync(oldPending.Attempt.Id, default))!.Status);
        Assert.Equal(EmailSendStatuses.Pending, (await ownerOne.GetAsync(freshPending.Attempt.Id, default))!.Status);
        Assert.Equal(EmailSendStatuses.Sending, (await ownerTwo.GetAsync(freshSending.Attempt.Id, default))!.Status);

        var userOneNotifications = await fixture.Notifications("user-1").ListAsync(10, default);
        var userTwoNotifications = await fixture.Notifications("user-2").ListAsync(10, default);
        Assert.Single(userOneNotifications);
        Assert.Single(userTwoNotifications);
        Assert.Equal("email.send.uncertain", userOneNotifications[0].Kind);
        Assert.Equal("email.send.stopped", userTwoNotifications[0].Kind);
        Assert.DoesNotContain("gmail", userOneNotifications[0].Message, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("microsoft", userTwoNotifications[0].Message, StringComparison.OrdinalIgnoreCase);
    }

    private static string Hash(char value) => new(value, 64);

    private sealed class Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options, int jobId, int otherJobId) : IAsyncDisposable
    {
        private readonly List<JobTrackerContext> contexts = new();
        public ManualTimeProvider Time { get; } = new(new DateTimeOffset(2026, 8, 10, 12, 0, 0, TimeSpan.Zero));
        public int JobId { get; } = jobId;
        public int OtherJobId { get; } = otherJobId;

        public static async Task<Fixture> CreateAsync()
        {
            var connection = new SqliteConnection("Data Source=:memory:");
            await connection.OpenAsync();
            var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
            await using var db = CreateDb(options, "user-1");
            await db.Database.EnsureCreatedAsync();
            var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
            db.Companies.Add(company);
            await db.SaveChangesAsync();
            var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" };
            db.JobApplications.Add(job);
            await db.SaveChangesAsync();
            await using var otherDb = CreateDb(options, "user-2");
            var otherCompany = new Company { Name = "Other", OwnerUserId = "user-2" };
            otherDb.Companies.Add(otherCompany);
            await otherDb.SaveChangesAsync();
            var otherJob = new JobApplication { JobTitle = "Frontend", CompanyId = otherCompany.Id, OwnerUserId = "user-2" };
            otherDb.JobApplications.Add(otherJob);
            await otherDb.SaveChangesAsync();
            return new Fixture(connection, options, job.Id, otherJob.Id);
        }

        public EmailSendAttemptStore Store(string? userId)
        {
            var db = CreateDb(options, userId);
            contexts.Add(db);
            return new EmailSendAttemptStore(db, Time);
        }

        public UserNotificationStore Notifications(string userId)
        {
            var db = CreateDb(options, userId);
            contexts.Add(db);
            return new UserNotificationStore(db, Time);
        }

        public async Task HardDeleteJobAsync(string userId, int jobId)
        {
            await using var db = CreateDb(options, userId);
            var job = await db.JobApplications.SingleAsync(item => item.Id == jobId);
            db.JobApplications.Remove(job);
            await db.SaveChangesAsync();
        }

        public async Task<int> AttemptCountIgnoringFiltersAsync()
        {
            await using var db = CreateDb(options, null);
            return await db.EmailSendAttempts.IgnoreQueryFilters().CountAsync();
        }

        private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string? userId)
        {
            var currentUser = new Mock<ICurrentUserService>();
            currentUser.SetupGet(service => service.UserId).Returns(userId);
            return new JobTrackerContext(options, currentUser.Object);
        }

        public async ValueTask DisposeAsync()
        {
            foreach (var context in contexts) await context.DisposeAsync();
            await connection.DisposeAsync();
        }
    }

    private sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
    {
        public DateTimeOffset UtcNow { get; private set; } = now;
        public override DateTimeOffset GetUtcNow() => UtcNow;
        public void Advance(TimeSpan duration) => UtcNow += duration;
    }
}
