using System.Text.Json;
using Microsoft.EntityFrameworkCore;

public class DraftService : IDraftService
{
    private readonly AppDbContext _db;
    private readonly ILogger<DraftService> _logger;

    private static readonly HashSet<BatchStatus> _entryAllowedStatuses = new()
    {
        BatchStatus.Uploaded,
        BatchStatus.InEntry,
        BatchStatus.Rejected
    };

    public DraftService(AppDbContext db, ILogger<DraftService> logger)
    {
        _db = db;
        _logger = logger;
    }

    public async Task<DraftPayloadResponse> GetDraftAsync(Guid batchId)
    {
        var batch = await _db.DigitizationBatches
            .AsNoTracking()
            .Include(b => b.DraftPatient)
            .Include(b => b.DraftEncounter)
            .Include(b => b.DraftObservations)
            .FirstOrDefaultAsync(b => b.Id == batchId);

        if (batch is null)
            throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");

        var ocrResult = await _db.OcrResults
            .AsNoTracking()
            .FirstOrDefaultAsync(o => o.BatchId == batchId);

        OcrConfidenceMap? ocrConfidence = null;
        if (ocrResult is not null)
        {
            var fieldConfidences = JsonSerializer.Deserialize<Dictionary<string, double>>(
                ocrResult.FieldConfidencesJson) ?? new Dictionary<string, double>();

            ocrConfidence = new OcrConfidenceMap(
                ocrResult.Provider,
                ocrResult.ProcessedAt,
                ocrResult.DurationMs,
                fieldConfidences);
        }

        return new DraftPayloadResponse(
            batch.Id,
            batch.Status.ToDbString(),
            batch.BatchType.ToDbString(),
            BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
            ocrConfidence,
            batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
            batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
            batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
        );
    }

    public async Task<DraftPatientDto> UpsertPatientAsync(
        Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId)
    {
        var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
        TransitionToInEntryIfNeeded(batch, actorUserId);

        BloodType? parsedBloodType = null;
        if (req.BloodType is not null)
        {
            if (!BloodTypeExtensions.TryFromDbString(req.BloodType, out var parsed))
                throw new ValidationException(
                    $"Invalid blood type '{req.BloodType}'. Allowed values: A+, A-, B+, B-, AB+, AB-, O+, O-.",
                    "INVALID_BLOOD_TYPE");
            parsedBloodType = parsed;
        }

        var newAllergiesJson = req.Allergies is not null
            ? JsonSerializer.Serialize(req.Allergies)
            : null;
        var newMedicationsJson = req.Medications is not null
            ? JsonSerializer.Serialize(req.Medications)
            : null;

        var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);

        if (patient is null)
        {
            patient = new DraftPatient
            {
                Id = Guid.NewGuid(),
                BatchId = batchId,
                FullName = req.FullName,
                DateOfBirth = req.DateOfBirth,
                Sex = req.Sex,
                BloodType = parsedBloodType,
                EmergencyContact = req.EmergencyContact,
                AllergiesJson = newAllergiesJson,
                NoKnownAllergies = req.NoKnownAllergies,
                MedicationsJson = newMedicationsJson,
                NoActiveMedications = req.NoActiveMedications,
                CreatedAt = DateTimeOffset.UtcNow,
                UpdatedAt = DateTimeOffset.UtcNow
            };
            _db.DraftPatients.Add(patient);
        }
        else
        {
            var diffs = new List<object>();
            DiffField(diffs, "patient.fullName", patient.FullName, req.FullName);
            DiffField(diffs, "patient.dateOfBirth", patient.DateOfBirth?.ToString("yyyy-MM-dd"), req.DateOfBirth?.ToString("yyyy-MM-dd"));
            DiffField(diffs, "patient.sex", patient.Sex, req.Sex);
            DiffField(diffs, "patient.bloodType", patient.BloodType?.ToDbString(), parsedBloodType?.ToDbString());
            DiffField(diffs, "patient.emergencyContact", patient.EmergencyContact, req.EmergencyContact);
            DiffField(diffs, "patient.allergiesJson", patient.AllergiesJson, newAllergiesJson);
            DiffField(diffs, "patient.noKnownAllergies", patient.NoKnownAllergies.ToString(), req.NoKnownAllergies.ToString());
            DiffField(diffs, "patient.medicationsJson", patient.MedicationsJson, newMedicationsJson);
            DiffField(diffs, "patient.noActiveMedications", patient.NoActiveMedications.ToString(), req.NoActiveMedications.ToString());

            if (diffs.Count > 0)
                await WriteDraftFieldEventAsync(batchId, actorUserId, "patient", diffs);

            patient.FullName = req.FullName;
            patient.DateOfBirth = req.DateOfBirth;
            patient.Sex = req.Sex;
            patient.BloodType = parsedBloodType;
            patient.EmergencyContact = req.EmergencyContact;
            patient.AllergiesJson = newAllergiesJson;
            patient.NoKnownAllergies = req.NoKnownAllergies;
            patient.MedicationsJson = newMedicationsJson;
            patient.NoActiveMedications = req.NoActiveMedications;
            patient.UpdatedAt = DateTimeOffset.UtcNow;
        }

        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Draft patient upserted for batch {BatchId} by user {UserId}",
            batchId, actorUserId);

        return MapPatient(patient);
    }

    public async Task<DraftEncounterDto> UpsertEncounterAsync(
        Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId)
    {
        var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
        TransitionToInEntryIfNeeded(batch, actorUserId);

        Department? parsedDepartment = null;
        if (req.Department is not null)
        {
            if (!DepartmentExtensions.TryFromDbString(req.Department, out var parsed))
                throw new ValidationException(
                    $"Invalid department '{req.Department}'. Must be a recognized hospital department.",
                    "INVALID_DEPARTMENT");
            parsedDepartment = parsed;
        }

        var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId);

        if (encounter is null)
        {
            encounter = new DraftEncounter
            {
                Id = Guid.NewGuid(),
                BatchId = batchId,
                AdmissionDate = req.AdmissionDate,
                Department = parsedDepartment,
                RoomBed = req.RoomBed,
                AdmissionReason = req.AdmissionReason,
                DischargeDiagnosis = req.DischargeDiagnosis,
                Status = req.Status,
                CreatedAt = DateTimeOffset.UtcNow,
                UpdatedAt = DateTimeOffset.UtcNow
            };
            _db.DraftEncounters.Add(encounter);
        }
        else
        {
            var diffs = new List<object>();
            DiffField(diffs, "encounter.admissionDate", encounter.AdmissionDate?.ToString("O"), req.AdmissionDate?.ToString("O"));
            DiffField(diffs, "encounter.department", encounter.Department?.ToDbString(), parsedDepartment?.ToDbString());
            DiffField(diffs, "encounter.roomBed", encounter.RoomBed, req.RoomBed);
            DiffField(diffs, "encounter.admissionReason", encounter.AdmissionReason, req.AdmissionReason);
            DiffField(diffs, "encounter.dischargeDiagnosis", encounter.DischargeDiagnosis, req.DischargeDiagnosis);
            DiffField(diffs, "encounter.status", encounter.Status, req.Status);

            if (diffs.Count > 0)
                await WriteDraftFieldEventAsync(batchId, actorUserId, "encounter", diffs);

            encounter.AdmissionDate = req.AdmissionDate;
            encounter.Department = parsedDepartment;
            encounter.RoomBed = req.RoomBed;
            encounter.AdmissionReason = req.AdmissionReason;
            encounter.DischargeDiagnosis = req.DischargeDiagnosis;
            encounter.Status = req.Status;
            encounter.UpdatedAt = DateTimeOffset.UtcNow;
        }

        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Draft encounter upserted for batch {BatchId} by user {UserId}",
            batchId, actorUserId);

        return MapEncounter(encounter);
    }

    public async Task<DraftObservationDto> AddObservationAsync(
        Guid batchId, CreateDraftObservationRequest req, Guid actorUserId)
    {
        var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
        TransitionToInEntryIfNeeded(batch, actorUserId);

        // Plausibility check — reject impossible values before persisting
        if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
            throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");

        var observation = new DraftObservation
        {
            Id = Guid.NewGuid(),
            BatchId = batchId,
            ObservationCode = req.ObservationCode,
            Value = req.Value,
            Unit = req.Unit,
            RecordedAt = req.RecordedAt,
            Note = req.Note,
            CreatedAt = DateTimeOffset.UtcNow
        };

        _db.DraftObservations.Add(observation);
        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Draft observation {ObservationId} added to batch {BatchId} by user {UserId}",
            observation.Id, batchId, actorUserId);

        return MapObservation(observation);
    }

    public async Task<DraftObservationDto> UpdateObservationAsync(
        Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId)
    {
        var batch = await LoadBatchForEntryAsync(batchId, actorUserId);

        var observation = await _db.DraftObservations
            .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId);

        if (observation is null)
            throw new NotFoundException(
                "Observation not found in this batch.", "OBSERVATION_NOT_FOUND");

        // Re-validate plausibility on the new value
        if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
            throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");

        var diffs = new List<object>();
        DiffField(diffs, "observationCode", observation.ObservationCode, req.ObservationCode);
        DiffField(diffs, "value", observation.Value.ToString(), req.Value.ToString());
        DiffField(diffs, "unit", observation.Unit, req.Unit);
        DiffField(diffs, "recordedAt", observation.RecordedAt.ToString("O"), req.RecordedAt.ToString("O"));
        DiffField(diffs, "note", observation.Note, req.Note);

        if (diffs.Count > 0)
            await WriteDraftFieldEventAsync(batchId, actorUserId, $"observation:{observationId}", diffs);

        observation.ObservationCode = req.ObservationCode;
        observation.Value = req.Value;
        observation.Unit = req.Unit;
        observation.RecordedAt = req.RecordedAt;
        observation.Note = req.Note;

        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Draft observation {ObservationId} updated in batch {BatchId} by user {UserId}",
            observationId, batchId, actorUserId);

        return MapObservation(observation);
    }

    public async Task DeleteObservationAsync(
        Guid batchId, Guid observationId, Guid actorUserId)
    {
        var batch = await LoadBatchForEntryAsync(batchId, actorUserId);

        var observation = await _db.DraftObservations
            .FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId);

        if (observation is null)
            throw new NotFoundException(
                "Observation not found in this batch.", "OBSERVATION_NOT_FOUND");

        _db.DigitizationEvents.Add(new DigitizationEvent
        {
            Id = Guid.NewGuid(),
            BatchId = batchId,
            EventType = DigitizationEventType.DraftObservationDeleted,
            ActorUserId = actorUserId,
            OccurredAt = DateTimeOffset.UtcNow,
            MetadataJson = JsonSerializer.Serialize(new
            {
                observationId,
                observationCode = observation.ObservationCode,
                value = observation.Value,
                unit = observation.Unit,
                recordedAt = observation.RecordedAt
            })
        });

        _db.DraftObservations.Remove(observation);
        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Draft observation {ObservationId} deleted from batch {BatchId} by user {UserId}",
            observationId, batchId, actorUserId);
    }

    public async Task<DigitizationBatch> SubmitForVerificationAsync(
        Guid batchId, Guid actorUserId)
    {
        var batch = await _db.DigitizationBatches
            .Include(b => b.DraftPatient)
            .Include(b => b.DraftEncounter)
            .Include(b => b.DraftObservations)
            .FirstOrDefaultAsync(b => b.Id == batchId);

        if (batch is null)
            throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");

        if (batch.Status != BatchStatus.InEntry)
            throw new ConflictException(
                $"Only batches in 'in_entry' status can be submitted for verification. " +
                $"Current status: '{batch.Status.ToDbString()}'.",
                "ILLEGAL_STATUS_TRANSITION");

        // Batch-type-specific completeness validation
        ValidateCompleteness(batch);

        // Transition to PendingVerification
        batch.Status = BatchStatus.PendingVerification;
        batch.UpdatedAt = DateTimeOffset.UtcNow;

        _db.DigitizationEvents.Add(new DigitizationEvent
        {
            Id = Guid.NewGuid(),
            BatchId = batchId,
            EventType = DigitizationEventType.SubmittedForVerification,
            ActorUserId = actorUserId,
            OccurredAt = DateTimeOffset.UtcNow,
            MetadataJson = JsonSerializer.Serialize(new
            {
                batchType = batch.BatchType.ToDbString(),
                observationCount = batch.DraftObservations.Count
            })
        });

        await _db.SaveChangesAsync();

        _logger.LogInformation(
            "Batch {BatchId} submitted for verification by user {UserId}. " +
            "Type={BatchType}, ObservationCount={ObsCount}",
            batchId, actorUserId,
            batch.BatchType.ToDbString(),
            batch.DraftObservations.Count);

        return batch;
    }

    // ─── Private helpers ─────────────────────────────────────────

    /// <summary>
    /// Loads the batch and validates that its current status allows data entry.
    /// Enforces assignment: only the assigned entry clerk (enteredByUserId) or an
    /// administrator may save draft fields. Throws BATCH_NOT_ASSIGNED otherwise.
    /// </summary>
    private async Task<DigitizationBatch> LoadBatchForEntryAsync(Guid batchId, Guid actorUserId)
    {
        var batch = await _db.DigitizationBatches.FindAsync(batchId);

        if (batch is null)
            throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");

        if (!_entryAllowedStatuses.Contains(batch.Status))
            throw new ConflictException(
                $"Data entry is not allowed for batches in '{batch.Status.ToDbString()}' status. " +
                $"Allowed statuses: UPLOADED, IN_ENTRY, REJECTED.",
                "ENTRY_NOT_ALLOWED");

        if (batch.EnteredByUserId.HasValue && batch.EnteredByUserId.Value != actorUserId)
        {
            var actor = await _db.Users.AsNoTracking()
                .FirstOrDefaultAsync(u => u.Id == actorUserId);
            if (actor?.Role != UserRole.Administrator)
            {
                throw new ConflictException(
                    "This batch is assigned to another entry clerk.",
                    "BATCH_NOT_ASSIGNED");
            }
        }

        return batch;
    }

    private void TransitionToInEntryIfNeeded(DigitizationBatch batch, Guid actorUserId)
    {
        if (batch.Status == BatchStatus.InEntry) return;

        var previousStatus = batch.Status.ToDbString();
        batch.Status = BatchStatus.InEntry;
        batch.UpdatedAt = DateTimeOffset.UtcNow;

        _db.DigitizationEvents.Add(new DigitizationEvent
        {
            Id = Guid.NewGuid(),
            BatchId = batch.Id,
            EventType = DigitizationEventType.EntryStarted,
            ActorUserId = actorUserId,
            OccurredAt = DateTimeOffset.UtcNow,
            MetadataJson = JsonSerializer.Serialize(new
            {
                previousStatus,
                newStatus = "IN_ENTRY"
            })
        });

        _logger.LogInformation(
            "Batch {BatchId} transitioned from {PreviousStatus} to IN_ENTRY",
            batch.Id, previousStatus);
    }

    /// <summary>
    /// Validates batch-type-specific completeness rules. Throws ValidationException
    /// with a descriptive message listing all missing fields.
    /// </summary>
    private static void ValidateCompleteness(DigitizationBatch batch)
    {
        var errors = new List<string>();

        switch (batch.BatchType)
        {
            case BatchType.PatientRegistration:
                ValidatePatientRegistration(batch, errors);
                break;

            case BatchType.VitalsSheet:
                ValidateVitalsSheet(batch, errors);
                break;

            case BatchType.LabResults:
                ValidateLabResults(batch, errors);
                break;

            case BatchType.AllergyUpdate:
                ValidateAllergyUpdate(batch, errors);
                break;

            case BatchType.EncounterSummary:
                ValidateEncounterSummary(batch, errors);
                break;

            case BatchType.MedicationList:
                ValidateMedicationList(batch, errors);
                break;

            case BatchType.Mixed:
                ValidateMixed(batch, errors);
                break;
        }

        if (errors.Count > 0)
        {
            var message = $"Batch is incomplete for type '{batch.BatchType.ToDbString()}'. " +
                          $"Missing: {string.Join("; ", errors)}.";
            throw new ValidationException(message, "BATCH_INCOMPLETE");
        }
    }

    private static void ValidatePatientRegistration(
        DigitizationBatch batch, List<string> errors)
    {
        // Required: Full name, date of birth, sex
        if (batch.DraftPatient is null)
        {
            errors.Add("Patient demographics are required");
            return;
        }

        if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
            errors.Add("Patient full name is required");

        if (batch.DraftPatient.DateOfBirth is null)
            errors.Add("Patient date of birth is required");

        if (string.IsNullOrWhiteSpace(batch.DraftPatient.Sex))
            errors.Add("Patient sex is required");
    }

    private static void ValidateVitalsSheet(
        DigitizationBatch batch, List<string> errors)
    {
        // Required: Linked patient, encounter context, >= 1 observation with recordedAt
        if (batch.DraftPatient is null)
            errors.Add("Linked patient is required for vitals sheets");

        if (batch.DraftEncounter is null)
            errors.Add("Encounter context is required for vitals sheets");

        if (batch.DraftObservations.Count == 0)
            errors.Add("At least one observation with a recorded timestamp is required");
        else if (batch.DraftObservations.Any(o => o.RecordedAt == default))
            errors.Add("All observations must have a recorded timestamp");
    }

    private static void ValidateLabResults(
        DigitizationBatch batch, List<string> errors)
    {
        // Corrections inherit patient and encounter from the superseded promoted batch
        if (!batch.SupersedesBatchId.HasValue)
        {
            if (batch.DraftPatient is null)
                errors.Add("Linked patient is required for lab results");

            if (batch.DraftEncounter is null)
                errors.Add("Encounter context is required for lab results");
        }

        if (batch.DraftObservations.Count == 0)
            errors.Add("At least one lab observation is required");
        else if (batch.DraftObservations.Any(o => o.RecordedAt == default))
            errors.Add("All observations must have a recorded timestamp");
    }

    private static void ValidateAllergyUpdate(
        DigitizationBatch batch, List<string> errors)
    {
        // Required: Linked patient, allergies list (may be empty with noKnownAllergies: true)
        if (batch.DraftPatient is null)
        {
            errors.Add("Linked patient is required for allergy updates");
            return;
        }

        var hasAllergies = !string.IsNullOrWhiteSpace(batch.DraftPatient.AllergiesJson);
        var hasNoKnownAllergiesFlag = batch.DraftPatient.NoKnownAllergies;

        // Either the allergies list must be present OR noKnownAllergies must be true
        if (!hasAllergies && !hasNoKnownAllergiesFlag)
            errors.Add("Allergies list is required (set noKnownAllergies to true if none)");
    }

    private static void ValidateEncounterSummary(
        DigitizationBatch batch, List<string> errors)
    {
        if (batch.DraftPatient is null)
            errors.Add("Linked patient is required for encounter summaries");

        if (batch.DraftEncounter is null)
        {
            errors.Add("Encounter context is required for encounter summaries");
            return;
        }

        if (batch.DraftEncounter.AdmissionDate is null)
            errors.Add("Admission date is required for encounter summaries");

        if (batch.DraftEncounter.Department is null)
            errors.Add("Department is required for encounter summaries");

        if (string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason))
            errors.Add("Admission reason is required for encounter summaries");
    }

    private static void ValidateMedicationList(
        DigitizationBatch batch, List<string> errors)
    {
        if (batch.DraftPatient is null)
        {
            errors.Add("Linked patient is required for medication lists");
            return;
        }

        var hasMedications = !string.IsNullOrWhiteSpace(batch.DraftPatient.MedicationsJson);
        if (!hasMedications && !batch.DraftPatient.NoActiveMedications)
            errors.Add("Medications list is required (set noActiveMedications to true if none)");
    }

    private static void ValidateMixed(
        DigitizationBatch batch, List<string> errors)
    {
        if (batch.DraftPatient is null)
            errors.Add("Linked patient is required for mixed batches");

        if (batch.DraftEncounter is null)
            errors.Add("Encounter context is required for mixed batches");

        var hasObservations = batch.DraftObservations.Count > 0
            && batch.DraftObservations.All(o => o.RecordedAt != default);

        var hasEncounterSummary = batch.DraftEncounter is not null
            && batch.DraftEncounter.AdmissionDate is not null
            && batch.DraftEncounter.Department is not null
            && !string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason);

        if (!hasObservations && !hasEncounterSummary)
            errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)");
    }

    // ─── Field-level audit helpers ────────────────────────────────

    private static void DiffField(List<object> diffs, string field, string? oldValue, string? newValue)
    {
        if (string.Equals(oldValue ?? "", newValue ?? "", StringComparison.Ordinal))
            return;

        diffs.Add(new { field, oldValue = oldValue ?? "", newValue = newValue ?? "" });
    }

    private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Writes a DraftFieldUpdated event. If an event of the same type was written
    /// for this batch within the last 30 seconds, merges the new fields into the
    /// existing event's metadata to prevent audit noise from auto-save.
    /// </summary>
    private async Task WriteDraftFieldEventAsync(
        Guid batchId, Guid actorUserId, string section, List<object> fieldsChanged)
    {
        var cutoff = DateTimeOffset.UtcNow.Add(-DebounceWindow);

        var recent = await _db.DigitizationEvents
            .Where(e => e.BatchId == batchId
                        && e.EventType == DigitizationEventType.DraftFieldUpdated
                        && e.ActorUserId == actorUserId
                        && e.OccurredAt >= cutoff)
            .OrderByDescending(e => e.OccurredAt)
            .FirstOrDefaultAsync();

        if (recent is not null)
        {
            var existing = !string.IsNullOrWhiteSpace(recent.MetadataJson)
                ? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(recent.MetadataJson)
                : new Dictionary<string, JsonElement>();

            var existingChanges = existing!.TryGetValue("fieldsChanged", out var fc)
                ? JsonSerializer.Deserialize<List<object>>(fc.GetRawText()) ?? new List<object>()
                : new List<object>();

            existingChanges.AddRange(fieldsChanged);

            existing["section"] = JsonSerializer.SerializeToElement(section);
            existing["fieldsChanged"] = JsonSerializer.SerializeToElement(existingChanges);

            recent.MetadataJson = JsonSerializer.Serialize(existing);
            recent.OccurredAt = DateTimeOffset.UtcNow;
        }
        else
        {
            _db.DigitizationEvents.Add(new DigitizationEvent
            {
                Id = Guid.NewGuid(),
                BatchId = batchId,
                EventType = DigitizationEventType.DraftFieldUpdated,
                ActorUserId = actorUserId,
                OccurredAt = DateTimeOffset.UtcNow,
                MetadataJson = JsonSerializer.Serialize(new
                {
                    section,
                    fieldsChanged
                })
            });
        }
    }

    // ─── Mapping helpers ─────────────────────────────────────────

    private static DraftPatientDto MapPatient(DraftPatient p) => new(
        p.Id,
        p.FullName,
        p.DateOfBirth,
        p.Sex,
        p.BloodType?.ToDbString(),
        p.EmergencyContact,
        !string.IsNullOrWhiteSpace(p.AllergiesJson)
            ? JsonSerializer.Deserialize<List<string>>(p.AllergiesJson)
            : null,
        p.NoKnownAllergies,
        !string.IsNullOrWhiteSpace(p.MedicationsJson)
            ? JsonSerializer.Deserialize<List<string>>(p.MedicationsJson)
            : null,
        p.NoActiveMedications,
        p.UpdatedAt
    );

    private static DraftEncounterDto MapEncounter(DraftEncounter e) => new(
        e.Id,
        e.AdmissionDate,
        e.Department?.ToDbString(),
        e.RoomBed,
        e.AdmissionReason,
        e.DischargeDiagnosis,
        e.Status,
        e.UpdatedAt
    );

    private static DraftObservationDto MapObservation(DraftObservation o) => new(
        o.Id,
        o.ObservationCode,
        o.Value,
        o.Unit,
        o.RecordedAt,
        o.Note,
        o.CreatedAt
    );
}