#nullable enable
using System.Reflection;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Audit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Baya.Infrastructure.Persistence.Interceptors;

/// <summary>
/// On every save this interceptor does two things in the caller's transaction:
/// (1) stamps <c>CreatedAt</c>/<c>CreatedById</c> on insert and <c>ModifiedAt</c>/<c>ModifiedById</c> on
/// update (from <see cref="IDateTimeProvider"/> + <see cref="ICurrentUser"/>); and
/// (2) appends an immutable <c>audit_logs</c> row for every change to an <see cref="IAuditable"/> entity,
/// with a redacted old/new diff. The audit rows ride the same <c>SaveChanges</c>, so a config change and
/// its audit entry commit atomically. Handlers never set audit fields or write audit rows themselves.
/// </summary>
public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider)
    : SaveChangesInterceptor
{
    private const string RedactedMarker = "<redacted>";

    private static readonly HashSet<string> NonBusinessFields =
        ["Id", "CreatedAt", "ModifiedAt", "CreatedById", "ModifiedById"];

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        Process(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        Process(eventData.Context);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void Process(DbContext? context)
    {
        if (context is null)
            return;

        var now = dateTimeProvider.UtcNow;
        var userId = currentUser.UserId;

        // Snapshot the entries before we add any audit rows, so adding to the context can't disturb the loop.
        var entries = context.ChangeTracker.Entries().ToList();

        Stamp(entries, now, userId);

        var auditLogs = CollectAuditLogs(entries, now, userId);
        if (auditLogs.Count > 0)
            context.Set<AuditLog>().AddRange(auditLogs);
    }

    private static void Stamp(IReadOnlyList<EntityEntry> entries, DateTimeOffset now, int? userId)
    {
        foreach (var entry in entries)
        {
            if (entry.Entity is not ITimeModification timed)
                continue;

            switch (entry.State)
            {
                case EntityState.Added:
                    timed.CreatedAt = now;
                    timed.ModifiedAt = now;
                    if (entry.Entity is IAuditableEntity addedAuditable)
                    {
                        addedAuditable.CreatedById = userId;
                        addedAuditable.ModifiedById = userId;
                    }

                    break;

                case EntityState.Modified:
                    timed.ModifiedAt = now;
                    if (entry.Entity is IAuditableEntity modifiedAuditable)
                        modifiedAuditable.ModifiedById = userId;

                    break;
            }
        }
    }

    private List<AuditLog> CollectAuditLogs(IReadOnlyList<EntityEntry> entries, DateTimeOffset now, int? userId)
    {
        var logs = new List<AuditLog>();

        foreach (var entry in entries)
        {
            if (entry.Entity is not IAuditable)
                continue;

            var (action, useOriginal) = entry.State switch
            {
                EntityState.Added => (AuditAction.Created, false),
                EntityState.Modified => (AuditAction.Updated, false),
                EntityState.Deleted => (AuditAction.Deleted, true),
                _ => (string.Empty, false)
            };

            if (action.Length == 0)
                continue;

            logs.Add(new AuditLog
            {
                EntityType = entry.Metadata.ClrType.Name,
                EntityId = ResolveEntityId(entry, useOriginal),
                Action = action,
                ChangedFieldsJson = BuildDiff(entry),
                ActorUserId = userId,
                OccurredAt = now
            });
        }

        return logs;
    }

    private static string ResolveEntityId(EntityEntry entry, bool useOriginal)
    {
        var keyProperty = entry.Metadata.FindPrimaryKey()?.Properties.FirstOrDefault();
        if (keyProperty is null)
            return string.Empty;

        var property = entry.Property(keyProperty.Name);
        var value = useOriginal ? property.OriginalValue : property.CurrentValue;
        return value?.ToString() ?? string.Empty;
    }

    // { "Field": { "old": <old>, "new": <new> } } for the changed business fields; PII columns marked
    // [AuditRedacted] are written as a redaction marker, never plaintext.
    private static string? BuildDiff(EntityEntry entry)
    {
        var diff = new Dictionary<string, object?>();

        foreach (var property in entry.Properties)
        {
            var name = property.Metadata.Name;
            if (NonBusinessFields.Contains(name))
                continue;

            var isDeletedOrAdded = entry.State is EntityState.Added or EntityState.Deleted;
            if (entry.State == EntityState.Modified && !property.IsModified)
                continue;

            var redacted = property.Metadata.PropertyInfo?.GetCustomAttribute<AuditRedactedAttribute>() is not null;

            object? oldValue = entry.State == EntityState.Added ? null : Sanitize(property.OriginalValue, redacted);
            object? newValue = entry.State == EntityState.Deleted ? null : Sanitize(property.CurrentValue, redacted);

            if (isDeletedOrAdded || !Equals(property.OriginalValue, property.CurrentValue))
                diff[name] = new { old = oldValue, @new = newValue };
        }

        return diff.Count == 0 ? null : JsonSerializer.Serialize(diff);
    }

    private static object? Sanitize(object? value, bool redacted) =>
        value is null ? null : redacted ? RedactedMarker : value;
}
