using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace GB5Shared.ChangeTracking
{
///
/// Compares two instances of the same DTO type and returns only the fields that changed.
/// Uses compiled delegates cached per type — zero reflection on
/// the hot path after the first call for a given type.
///
/// Only primitive-value properties are tracked: bool, numeric, string, decimal,
/// DateTime, DateTimeOffset, Guid, and their nullable equivalents.
/// Complex navigation properties, collections, and byte arrays are intentionally skipped.
///
/// Usage (UPDATE path only — do not call for INSERT):
///
/// var changes = ChangeTracker.Compare(oldDto, newDto);
/// if (changes.Count > 0)
/// outboxMessage.Changes = JsonSerializer.Serialize(changes);
///
///
public static class ChangeTracker
{
// ── Cache ────────────────────────────────────────────────────────────────
// Keyed by concrete Type; each value is an array of (PropertyName, compiled getter).
// Built once per type on first call; never mutated after population.
private static readonly ConcurrentDictionary _cache = new();
// ── Public API ───────────────────────────────────────────────────────────
///
/// Returns the list of properties whose value changed between
/// and .
/// Returns an empty list — not null — when there are no differences.
/// Returns an empty list when is null (INSERT path guard).
///
public static List Compare(T? oldObj, T newObj) where T : class
{
if (oldObj is null || newObj is null)
return [];
var accessors = _cache.GetOrAdd(typeof(T), BuildAccessors);
var changes = new List(accessors.Length);
foreach (var acc in accessors)
{
var oldVal = acc.Get(oldObj)?.ToString();
var newVal = acc.Get(newObj)?.ToString();
if (!string.Equals(oldVal, newVal, StringComparison.Ordinal))
changes.Add(new ChangeRecordDTO(acc.Name, oldVal, newVal));
}
return changes;
}
// ── Internal — compiled delegate builder ────────────────────────────────
private static PropertyAccessor[] BuildAccessors(Type type)
{
var param = Expression.Parameter(typeof(object), "obj");
var cast = Expression.Convert(param, type);
return type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && IsTrackedType(p.PropertyType))
.Select(p =>
{
// Build: (object obj) => (object)((T)obj).PropertyName
var propAccess = Expression.Property(cast, p);
var boxed = Expression.Convert(propAccess, typeof(object));
var getter = Expression.Lambda>(boxed, param).Compile();
return new PropertyAccessor(p.Name, getter);
})
.ToArray();
}
///
/// Returns true for types whose string representation is meaningful for diffing.
/// Nullable<T> is unwrapped to its underlying type for the check.
///
private static bool IsTrackedType(Type t)
{
var underlying = Nullable.GetUnderlyingType(t) ?? t;
return underlying.IsPrimitive // bool, int, long, double, float, char, byte, …
|| underlying == typeof(string)
|| underlying == typeof(decimal)
|| underlying == typeof(DateTime)
|| underlying == typeof(DateTimeOffset)
|| underlying == typeof(Guid);
}
// ── Inner record ─────────────────────────────────────────────────────────
private sealed record PropertyAccessor(string Name, Func