using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Reflection;
using e_suite.Database.Audit.Attributes;
using e_suite.Database.Audit.Extensions;
using e_suite.Database.Audit.Tables.Audit;
using eSuite.Core.Clock;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace e_suite.Database.Audit.AuditEngine;

public class AuditEngineCore
{
    private readonly AuditableEntityContext _auditableEntityContext;
    private readonly IClock _clock;

    public AuditEngineCore(AuditableEntityContext auditableEntityContext, IClock clock)
    {
        _auditableEntityContext = auditableEntityContext;
        _clock = clock;
    }

#pragma warning disable IDE0060 // Remove unused parameter
    public List<AuditRecorder> OnBeforeSaveChanges(AuditableEntityContext dbContext, AuditUserDetails auditUserDetails, CancellationToken cancellationToken)
#pragma warning restore IDE0060 // Remove unused parameter
    {
        if (!_auditableEntityContext.ChangeTracker.AutoDetectChangesEnabled)
        {
            _auditableEntityContext.ChangeTracker.DetectChanges();
        }

        var auditEntries = new List<AuditRecorder>();
        foreach (var entry in _auditableEntityContext.ChangeTracker.Entries())
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (HasNoAuditAttribute(entry.Entity) || entry.State == EntityState.Detached
                                                  || entry.State == EntityState.Unchanged)
                continue;

            var auditEntry = new AuditRecorder(entry, auditUserDetails, _clock);

            auditEntry.AuditType = entry.State switch
            {
                EntityState.Added => AuditType.Create,
                EntityState.Deleted => AuditType.Purge,
                EntityState.Modified => AuditType.Update,
                _ => auditEntry.AuditType
            };

            foreach (var property in entry.Properties)
            {
                cancellationToken.ThrowIfCancellationRequested();

                if (property.IsTemporary)
                {
                    continue;
                }

                var propertyName = property.Metadata.Name;

                var propertyInfo = entry.Entity.GetType().GetProperties().Single(x => x.Name == propertyName);

                var isLastUpdated = propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
                if (isLastUpdated)
                {
                    continue;
                }

                // Value converter + comparer
                var converter = property.Metadata.GetValueConverter();  //todo need a unit test for when a value converter is present on the table.
                var comparer = property.Metadata.GetValueComparer();

                // Raw CLR values
                var currentClr = property.CurrentValue;
                var oldClr = property.OriginalValue;

                // Convert to provider (JSON for Tasks)
                var currentValue = converter?.ConvertToProvider(currentClr) ?? currentClr;
                var oldValue = converter?.ConvertToProvider(oldClr) ?? oldClr;
                
                var isSoftDelete =
                    propertyInfo.CustomAttributes.SingleOrDefault(x =>
                        x.AttributeType == typeof(AuditSoftDeleteAttribute));
                if (isSoftDelete != null && auditEntry.AuditType == AuditType.Update)
                {
                    if (property.IsModified)
                    {
                        if (!currentValue!.Equals(oldValue))
                        {
                            var deletedValue = isSoftDelete.ConstructorArguments[0];
                            auditEntry.AuditType = deletedValue.Value!.Equals(currentValue)
                                ? AuditType.Delete
                                : AuditType.Restore;
                            continue;
                        }
                    }
                }

                var isRedacted =
                    propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(RedactAuditAttribute));

                var currentDisplayName = GetNewName(property) ?? GetEnumAsString(currentValue);
                var oldDisplayName = GetOldName(property) ?? GetEnumAsString(oldValue);

                // Compare using EF’s own comparer if available
                var valuesMatch = comparer != null
                    ? comparer.Equals(currentClr, oldClr)
                    : Equals(currentValue, oldValue);
                
                if (isRedacted)
                {
                    const string redacted = "<Redacted>";

                    currentValue = redacted;
                    oldValue = redacted;

                    currentDisplayName = null;
                    oldDisplayName = null;
                }

                switch (entry.State)
                {
                    case EntityState.Added:
                        auditEntry.Fields[propertyName] = new Change
                        {
                            NewValue = currentValue,
                            NewDisplayName = currentDisplayName
                        };
                        break;
                    case EntityState.Deleted:
                        auditEntry.Fields[propertyName] = new Change
                        {
                            OldValue = oldValue,
                            OldDisplayName = oldDisplayName
                        };
                        break;
                    case EntityState.Modified:
                        if (!valuesMatch)
                        {
                            auditEntry.Fields[propertyName] = new Change
                            {
                                OldValue = oldValue,
                                OldDisplayName = oldDisplayName,
                                NewValue = currentValue,
                                NewDisplayName = currentDisplayName
                            };
                        }

                        break;
                }
            }

            if (auditEntry.Fields.Count != 0 | auditEntry.AuditType != AuditType.Update )
                auditEntries.Add(auditEntry);
        }

        return auditEntries;
    }

    public void OnAfterSaveChanges(AuditableEntityContext dbContext, List<AuditRecorder> auditEntries, CancellationToken cancellationToken)
    {
        foreach (var auditEntry in auditEntries)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (auditEntry.Entry != null)
                auditEntry.DrillUp = TraceDrillUp(dbContext, auditEntry.Entry.Entity, cancellationToken);

            AddAuditEntry(auditEntry, cancellationToken);
        }
    }

    private void AddAuditEntry(AuditRecorder auditEntry, CancellationToken cancellationToken)
    {
        var auditLog = _auditableEntityContext.AuditDetails.Add(auditEntry.ToAudit());
        cancellationToken.ThrowIfCancellationRequested();
        var childAuditDrillUp = auditEntry.DrillUp;

        var childDrillDownEntity = new AuditEntry
        {
            AuditLog = auditLog.Entity,
            DisplayName = childAuditDrillUp.EntryName,
            EntityName = childAuditDrillUp.EntityName,
            PrimaryKey = childAuditDrillUp.ToPrimaryKeyJson(),
        };
        _auditableEntityContext.AuditEntries.Add(childDrillDownEntity);

        childDrillDownEntity.IsPrimary = true;

        foreach (var parent in childAuditDrillUp.Parents)
        {
            cancellationToken.ThrowIfCancellationRequested();
            AddParent(auditLog.Entity, childDrillDownEntity, parent, cancellationToken);
        }
    }

    private void AddParent(AuditDetail auditLog, AuditEntry childDrillDownEntity, AuditDrillUp parent, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var parentDrillDownEntity = new AuditEntry
        {
            AuditLog = auditLog,
            DisplayName = parent.EntryName,
            EntityName = parent.EntityName,
            PrimaryKey = parent.ToPrimaryKeyJson(),
        };
        _auditableEntityContext.AuditEntries.Add(childDrillDownEntity);

        _auditableEntityContext.AuditDrillHierarchies.Add(new AuditDrillHierarchy
        {
            ParentAuditDrillDownEntity = parentDrillDownEntity,
            ChildAuditDrillDownEntity = childDrillDownEntity
        });

        foreach (var grandparent in parent.Parents)
            AddParent(auditLog, parentDrillDownEntity, grandparent, cancellationToken);
    }

    private AuditDrillUp TraceDrillUp(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
    {
        var type = entity.GetType();
        var drillUp = new AuditDrillUp
        {
            EntityName = type.FullName ?? type.Name,
        };

        try
        {
            drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
        }
        catch
        {
            LoadForeignKeysIfNotAlreadyLoaded(dbContext, entity, cancellationToken);
            drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
        }

        var properties = type.GetProperties();
        foreach (var property in properties)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var isPrimaryKey = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null;
            if (isPrimaryKey)
            {
                drillUp.PrimaryKey[property.Name] = property.GetValue(entity);
            }

            var isLastUpdated = property.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
            if (isLastUpdated)
            {
                property.SetValue(entity, _clock.GetNow);
            }

            var isParent = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditParentAttribute)) != null;
            if (isParent)
            {
                LoadParentIfNotAlreadyLoaded(dbContext, property, entity, cancellationToken);

                var parent = property.GetValue(entity);
                if (parent != null)
                {
                    var parentDrillUp = TraceDrillUp(dbContext, parent, cancellationToken);
                    drillUp.Parents.Add(parentDrillUp);
                }
            }
        }

        return drillUp;
    }

    private static void LoadForeignKeysIfNotAlreadyLoaded(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var type = entity.GetType();
        var properties = type.GetProperties();

        foreach (var property in properties)
        {
            var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
            var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
            if (lookupFieldName == null)
                continue;

            var lookupField = type.GetProperty(lookupFieldName);
            var lookupValue = lookupField?.GetValue(entity);
            if (lookupValue == null)
                continue;

            cancellationToken.ThrowIfCancellationRequested();
            var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
            if (dbSetProperty == null)
                continue;

            cancellationToken.ThrowIfCancellationRequested();
            if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
                continue;


            cancellationToken.ThrowIfCancellationRequested();
            var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
            if (primaryKeyProperty == null)
                continue;

            cancellationToken.ThrowIfCancellationRequested();
            var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);

            cancellationToken.ThrowIfCancellationRequested();
            property.SetValue(entity, result);
        }
    }

    private static void LoadParentIfNotAlreadyLoaded(AuditableEntityContext dbContext, PropertyInfo property, object entity, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var parent = property.GetValue(entity);
        if (parent != null)
            return;

        cancellationToken.ThrowIfCancellationRequested();
        var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
        var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
        if (lookupFieldName == null)
            return;

        cancellationToken.ThrowIfCancellationRequested();
        var entityType = entity.GetType();
        var lookupField = entityType.GetProperty(lookupFieldName);
        var lookupValue = lookupField?.GetValue(entity);
        if (lookupValue == null)
            return;

        cancellationToken.ThrowIfCancellationRequested();
        var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
        if (dbSetProperty == null)
            return;

        cancellationToken.ThrowIfCancellationRequested();
        if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
            return;


        cancellationToken.ThrowIfCancellationRequested();
        var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
        if (primaryKeyProperty == null)
            return;

        cancellationToken.ThrowIfCancellationRequested();
        var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);

        cancellationToken.ThrowIfCancellationRequested();
        property.SetValue(entity, result);
    }

    private static object FindObjectByProperty(IQueryable query, PropertyInfo primaryKeyProperty, object lookupValue)
    {
        var parameter = Expression.Parameter(query.ElementType);
        var propExpr = Expression.Property(parameter, primaryKeyProperty);
        var lambdaBody = Expression.Equal(propExpr, Expression.Constant(lookupValue, primaryKeyProperty.PropertyType));
        var filterEFn = Expression.Lambda(lambdaBody, parameter);
        return query.Where(filterEFn).FirstOrDefault();
    }

    private static PropertyInfo? GetPrimaryKeyProperty(Type entityType)
    {
        var entityProperties = entityType.GetProperties();

        foreach (var entityProperty in entityProperties)
        {
            if (entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null)
            {
                return entityProperty;
            }
        }

        return null;
    }

    private static PropertyInfo? FindDbSet(AuditableEntityContext dbContext, Type type)
    {
        var thisProperties = dbContext.GetType().GetProperties();
        foreach (var propertyInfo in thisProperties)
        {
            if (!propertyInfo.PropertyType.IsGenericType)
                continue;

            if (propertyInfo.PropertyType.GetGenericTypeDefinition() != typeof(DbSet<>))
                continue;

            if (propertyInfo.PropertyType.GenericTypeArguments[0] == type)
                return propertyInfo;
        }

        return null;
    }

    private static string? GetEnumAsString(object? enumValue)
    {
        if (enumValue == null || !enumValue.GetType().IsEnum)
            return null;

        var displayAttribute = ((Enum)enumValue).GetAttribute<DisplayAttribute>();
        return displayAttribute != null ? displayAttribute.Name : enumValue.ToString();
    }

    private static string? GetNewName(PropertyEntry property)
    {
        var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
        if (foreignKeyPropertyName == null)
            return null;

        var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);

        if (foreignKeyPropertyType == null)
            return null;

        var value = property.EntityEntry.Entity.GetType().GetProperty(foreignKeyPropertyType.Name)
            ?.GetValue(property.EntityEntry.Entity, null);

        return GetAuditName(value);
    }

    private static string? GetAuditName(object? value)
    {
        if (value == null)
            return null;
        
        var properties = value.GetType().GetProperties();
        foreach (var propertyInfo in properties)
        {
            var auditNameProperty =
                propertyInfo.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditNameAttribute));
            if (auditNameProperty == null)
                continue;

            var actualValue = propertyInfo.GetValue(value, null);
            {
                return actualValue?.ToString();
            }
        }

        return null;
    }

    private string? GetOldName(PropertyEntry property)
    {
        if (property.OriginalValue == null)
            return null;

        var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
        if (foreignKeyPropertyName == null)
            return null;

        var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);

        if (foreignKeyPropertyType == null)
            return null;
        
        var dbSetProperty = FindDbSet(_auditableEntityContext, foreignKeyPropertyType.PropertyType)!;
        var dbSet = (dbSetProperty.GetValue(_auditableEntityContext) as IQueryable)!;
        var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType)!;
        var value = FindObjectByProperty(dbSet, primaryKeyProperty, property.OriginalValue!);
        
        return GetAuditName(value);
    }

    private static PropertyInfo? GetForeignKeyPropertyType(PropertyEntry property, string foreignKeyPropertyName)
    {
        var entityProperties = property.EntityEntry.Entity.GetType().GetProperties();
        foreach (var entityProperty in entityProperties)
        {
            if (entityProperty.Name == foreignKeyPropertyName)
            {
                return entityProperty;
            }
        }

        return null;
    }

    private static string? GetForeignKeyPropertyName(PropertyEntry property)
    {
        var propertyInfo = property.EntityEntry.Entity.GetType().GetProperties();
        foreach (var entityProperty in propertyInfo)
        {
            var foreignKeyAttributeData = entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
            if (foreignKeyAttributeData != null)
            {
                if (foreignKeyAttributeData.ConstructorArguments.Any(x => x.Value!.Equals(property.Metadata.Name)))
                {
                    return entityProperty.Name;
                }
            }
        }

        return null;
    }

    private static bool HasNoAuditAttribute(object entity)
    {
        var type = entity.GetType();

        foreach (var attribute in type.CustomAttributes)
        {
            if (attribute.AttributeType == typeof(NoAuditAttribute))
            {
                return true;
            }
        }

        return false;
    }
}