using e_suite.API.Common.exceptions;
using e_suite.Database.Core;
using e_suite.Database.Core.Models;
using e_suite.UnitTestCore;
using eSuite.Core.Miscellaneous;
using Microsoft.EntityFrameworkCore;
using Moq;
using NUnit.Framework;
using System.ComponentModel.DataAnnotations.Schema;
using e_suite.API.Common.models;
using e_suite.Database.Core.Tables.Domain;
using e_suite.Database.Core.Tables.UserManager;

namespace e_suite.API.Common.UnitTests;

[Patches(typeof(TargetUser))]
public class PatchDto
{
    [PatchMap(nameof(TargetUser.FirstName))]
    public string? FirstName { get; set; }
    [PatchMap(nameof(TargetUser.LastName))]
    public string? LastName { get; set; }
    [PatchMap(nameof(TargetUser.IsActive))]
    public bool? IsActive { get; set; }
}

public class TargetUser
{
    public string FirstName { get; set; } = "Original";
    public string LastName { get; set; } = "User";
    public bool IsActive { get; set; } = false;
}

[TestFixture]
public class PatchUnitTests : TestBase
{
    private Mock<IEsuiteDatabaseDbContext> _esuiteDatabaseDbContext;

    [SetUp]
    public void Setup()
    {
        _esuiteDatabaseDbContext = new Mock<IEsuiteDatabaseDbContext>();
    }


    [Test]
    public void ApplyTo_Throws_WhenTargetIsNull()
    {
        var patch = new Patch<PatchDto>(new PatchDto { FirstName = "Colin" }, _esuiteDatabaseDbContext.Object);

        Assert.ThrowsAsync<ArgumentNullException>(async () => await patch.ApplyTo(null));
    }

    [Test]
    public async Task ApplyTo_IgnoresNullValues()
    {
        var dto = new PatchDto
        {
            FirstName = null,
            LastName = "Updated"
        };

        var patch = new Patch<PatchDto>(dto, _esuiteDatabaseDbContext.Object);
        var target = new TargetUser();

        await patch.ApplyTo(target);

        Assert.That(target.FirstName, Is.EqualTo("Original")); // unchanged
        Assert.That(target.LastName, Is.EqualTo("Updated"));   // patched
    }

    [Test]
    public async Task ApplyTo_UpdatesNonNullValues()
    {
        var dto = new PatchDto
        {
            FirstName = "Colin",
            LastName = "Smith"
        };

        var patch = new Patch<PatchDto>(dto, _esuiteDatabaseDbContext.Object);
        var target = new TargetUser();

        await patch.ApplyTo(target);

        Assert.That(target.FirstName, Is.EqualTo("Colin"));
        Assert.That(target.LastName, Is.EqualTo("Smith"));
    }

    [Patches(typeof(TargetUser))]
    private class PatchDtoWithExtra
    {
        [PatchMap(nameof(TargetUser.FirstName))]
        public string? FirstName { get; set; }
        
        public string? NonExistent { get; set; }
    }

    [Test]
    public async Task ApplyTo_IgnoresPropertiesNotOnTarget()
    {
        var dto = new PatchDtoWithExtra
        {
            FirstName = "Updated",
            NonExistent = "Ignored"
        };

        var patch = new Patch<PatchDtoWithExtra>(dto, _esuiteDatabaseDbContext.Object);
        var target = new TargetUser();

        await patch.ApplyTo(target);

        Assert.That(target.FirstName, Is.EqualTo("Updated"));
    }

    private class TargetWithReadOnly
    {
        public string FirstName { get; set; } = "Original";
        public string ReadOnlyProp => "Fixed";
    }
    
    [Test]
    public async Task ApplyTo_UpdatesNullableValueTypes()
    {
        var dto = new PatchDto
        {
            IsActive = true
        };

        var patch = new Patch<PatchDto>(dto, _esuiteDatabaseDbContext.Object);
        var target = new TargetUser();

        await patch.ApplyTo(target);

        Assert.That(target.IsActive, Is.True);
    }

    [Test]
    public async Task ApplyTo_MutatesTargetInPlace()
    {
        var dto = new PatchDto { FirstName = "Updated" };
        var patch = new Patch<PatchDto>(dto, _esuiteDatabaseDbContext.Object);

        var target = new TargetUser();
        var reference = target;

        await patch.ApplyTo(target);

        Assert.That(ReferenceEquals(reference, target), Is.True);
        Assert.That(target.FirstName, Is.EqualTo("Updated"));
    }

    [Patches(typeof(TargetUser))]
    public class NestedPatchDto
    {
        [PatchMap(nameof(TargetUser.FirstName))]
        public string? FirstName { get; set; }

        [PatchMap(nameof(TargetUser.FirstName))] //Note incorrectly mapped to make the code trigger exception.
        public Address? HomeAddress { get; set; }   // should trigger exception
    }

    public class Address
    {
        public string? Street { get; set; }
    }

    [Test]
    public void ApplyTo_Throws_WhenNestedObjectsArePresent()
    {
        var dto = new NestedPatchDto
        {
            FirstName = "Updated",
            HomeAddress = new Address { Street = "New Street" }
        };

        var patch = new Patch<NestedPatchDto>(dto, _esuiteDatabaseDbContext.Object);
        var target = new TargetUser();

        Assert.ThrowsAsync<InvalidDataException>( async () => await patch.ApplyTo(target));
    }

    [Test]
    public async Task ApplyTo_SetsNavigationAndForeignKey_WhenReferenceResolves()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var domain = new Domain { Id = 10 };
        db.Set<Domain>().Add(domain);
        await db.SaveChangesAsync();

        var user = new User(); // or even the real User if you like

        var dto = new PatchUser
        {
            Domain = new GeneralIdRef { Id = 10 }
        };

        var patch = new Patch<PatchUser>(dto, db);

        await patch.ApplyTo(user);

        Assert.That(user.Domain, Is.SameAs(domain));
        Assert.That(user.DomainId, Is.EqualTo(10));
    }

    [Test]
    public void ApplyTo_ThrowsNotFound_WhenEntityCannotBeResolved()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var user = new User();

        var dto = new PatchUser
        {
            Domain = new GeneralIdRef { Id = 999 }
        };

        var patch = new Patch<PatchUser>(dto, db);

        Assert.ThrowsAsync<NotFoundException>(() => patch.ApplyTo(user));
    }

    public class BadNav { public long Id { get; set; } }

    public class BadNavOwner
    {
        [ForeignKey(nameof(BadNavId))]
        public BadNav BadNav { get; set; } = null!;

        public long BadNavId { get; set; }
    }

    [Patches(typeof(BadNavOwner))]
    public class PatchBadNavOwner
    {
        [PatchMap(nameof(BadNavOwner.BadNav))]
        public GeneralIdRef? BadNav { get; set; }
    }

    [Test]
    public void ApplyTo_Throws_WhenNavigationDoesNotImplementIGeneralId()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var owner = new BadNavOwner();
        var dto = new PatchBadNavOwner
        {
            BadNav = new GeneralIdRef { Id = 1 }
        };

        var patch = new Patch<PatchBadNavOwner>(dto, db);

        var ex = Assert.ThrowsAsync<InvalidDataException>(() => patch.ApplyTo(owner));
        Assert.That(ex.Message, Does.Contain("does not implement IGeneralId"));
    }

    public class NoFkOwner
    {
        public Domain Domain { get; set; } = null!;
    }

    [Patches(typeof(NoFkOwner))]
    public class PatchNoFkOwner
    {
        [PatchMap(nameof(NoFkOwner.Domain))]
        public GeneralIdRef? Domain { get; set; }
    }

    [Test]
    public async Task ApplyTo_SetsNavigation_WhenForeignKeyMissing()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var domain = new Domain { Id = 5 };
        db.Set<Domain>().Add(domain);
        await db.SaveChangesAsync();

        var owner = new NoFkOwner();
        var dto = new PatchNoFkOwner
        {
            Domain = new GeneralIdRef { Id = 5 }
        };

        var patch = new Patch<PatchNoFkOwner>(dto, db);

        await patch.ApplyTo(owner);

        Assert.That(owner.Domain, Is.SameAs(domain));
    }

    [Test]
    public async Task ApplyTo_DoesNotChangeNavigation_WhenDtoValueIsNull()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var domain = new Domain { Id = 3 };
        db.Set<Domain>().Add(domain);
        await db.SaveChangesAsync();

        var user = new User
        {
            Domain = domain,
            DomainId = 3
        };

        var dto = new PatchUser
        {
            Domain = null
        };

        var patch = new Patch<PatchUser>(dto, db);

        await patch.ApplyTo(user);

        Assert.That(user.Domain, Is.SameAs(domain));
        Assert.That(user.DomainId, Is.EqualTo(3));
    }

    public class BrokenFkOwner
    {
        [ForeignKey("MissingFk")]
        public Domain Domain { get; set; } = null!;
    }

    [Patches(typeof(BrokenFkOwner))]
    public class PatchBrokenFkOwner
    {
        [PatchMap(nameof(BrokenFkOwner.Domain))]
        public GeneralIdRef? Domain { get; set; }
    }

    [Test]
    public async Task ApplyTo_DoesNotThrow_WhenForeignKeyPropertyMissing()
    {
        var options = new DbContextOptionsBuilder<EsuiteDatabaseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        using var db = new EsuiteDatabaseDbContext(options, _fakeClock);

        var domain = new Domain { Id = 7 };
        db.Set<Domain>().Add(domain);
        await db.SaveChangesAsync();

        var owner = new BrokenFkOwner();
        var dto = new PatchBrokenFkOwner
        {
            Domain = new GeneralIdRef { Id = 7 }
        };

        var patch = new Patch<PatchBrokenFkOwner>(dto, db);

        await patch.ApplyTo(owner);

        Assert.That(owner.Domain, Is.SameAs(domain));
    }

    [Patches(typeof(TargetUser))]
    private class PatchDtoWithBadMap
    {
        [PatchMap("DoesNotExist")]
        public string? FirstName { get; set; }
    }

    [Test]
    public void ApplyTo_Throws_WhenPatchMapTargetsUnknownProperty()
    {
        var dto = new PatchDtoWithBadMap
        {
            FirstName = "Updated"
        };

        var patch = new Patch<PatchDtoWithBadMap>(dto, _esuiteDatabaseDbContext.Object);

        var target = new TargetUser();

        var ex = Assert.ThrowsAsync<InvalidDataException>(() => patch.ApplyTo(target));

        Assert.That(ex.Message, Does.Contain("PatchMap refers to unknown property 'DoesNotExist'"));
        Assert.That(ex.Message, Does.Contain(nameof(TargetUser)));
    }

    [Patches(typeof(TargetWithReadOnly))]
    private class PatchDtoReadOnly
    {
        [PatchMap(nameof(TargetWithReadOnly.ReadOnlyProp))]
        public string? ReadOnlyProp { get; set; }
    }

    [Test]
    public void ApplyTo_Throws_WhenTargetPropertyIsNotWritable()
    {
        var dto = new PatchDtoReadOnly
        {
            ReadOnlyProp = "NewValue"
        };

        var patch = new Patch<PatchDtoReadOnly>(dto, _esuiteDatabaseDbContext.Object);

        var target = new TargetWithReadOnly();

        var ex = Assert.ThrowsAsync<InvalidDataException>(() => patch.ApplyTo(target));

        Assert.That(ex.Message, Does.Contain("is not writable"));
        Assert.That(ex.Message, Does.Contain(nameof(TargetWithReadOnly.ReadOnlyProp)));
    }

    private class TargetWritable
    {
        public string FirstName { get; set; } = "Original";
    }

    [Patches(typeof(TargetWritable))]
    private class PatchDtoWritable
    {
        [PatchMap(nameof(TargetWritable.FirstName))]
        public string? FirstName { get; set; }
    }

    [Test]
    public async Task ApplyTo_DoesNotThrow_WhenTargetPropertyIsWritable()
    {
        var dto = new PatchDtoWritable
        {
            FirstName = "Updated"
        };

        var patch = new Patch<PatchDtoWritable>(dto, _esuiteDatabaseDbContext.Object);

        var target = new TargetWritable();

        // Act + Assert: should NOT throw
        Assert.DoesNotThrowAsync(() => patch.ApplyTo(target));

        // And the property should be updated
        Assert.That(target.FirstName, Is.EqualTo("Updated"));
    }

    [Patches(typeof(TargetUser))]
    private class PatchDtoWithoutMaps
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
    }

    [Test]
    public void ApplyTo_Throws_WhenDtoHasNoPatchMapAttributes()
    {
        var dto = new PatchDtoWithoutMaps
        {
            FirstName = "Colin",
            LastName = "Smith"
        };

        var patch = new Patch<PatchDtoWithoutMaps>(dto, _esuiteDatabaseDbContext.Object);

        var target = new TargetUser();

        var ex = Assert.ThrowsAsync<InvalidDataException>(() => patch.ApplyTo(target));

        Assert.That(ex.Message, Does.Contain("does not define any properties with PatchMapAttribute"));
    }

    private class PatchDtoWithoutPatchesAttribute
    {
        [PatchMap("FirstName")]
        public string? FirstName { get; set; }
    }

    [Test]
    public void Constructor_Throws_WhenPatchesAttributeIsMissing()
    {
        var dto = new PatchDtoWithoutPatchesAttribute
        {
            FirstName = "Colin"
        };

        var ex = Assert.Throws<InvalidDataException>(() =>
            new Patch<PatchDtoWithoutPatchesAttribute>(dto, _esuiteDatabaseDbContext.Object));

        Assert.That(ex.Message, Does.Contain("is missing required [Patches] attribute"));
    }

    [Patches(typeof(TargetUser))]
    private class PatchUserDto
    {
        [PatchMap(nameof(TargetUser.FirstName))]
        public string? FirstName { get; set; }
    }

    private class WrongTargetType
    {
        public string FirstName { get; set; } = "Original";
    }

    [Test]
    public void ApplyTo_Throws_WhenAppliedToWrongTargetType()
    {
        var dto = new PatchUserDto
        {
            FirstName = "Updated"
        };

        var patch = new Patch<PatchUserDto>(dto, _esuiteDatabaseDbContext.Object);

        var wrongTarget = new WrongTargetType();

        var ex = Assert.ThrowsAsync<InvalidDataException>(() =>
            patch.ApplyTo(wrongTarget));

        Assert.That(ex.Message, Does.Contain("is defined to patch"));
        Assert.That(ex.Message, Does.Contain(nameof(TargetUser)));
        Assert.That(ex.Message, Does.Contain(nameof(WrongTargetType)));
    }
}