using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;

/// <summary>
/// Central-side singleton (per Bundle E wiring) that ingests batches of
/// <see cref="AuditEvent"/> rows pushed from sites via the
/// <c>IngestAuditEvents</c> gRPC RPC. Each row is stamped with the central-side
/// the central-side IngestedAtUtc (in DetailsJson) and inserted idempotently via
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — duplicates are
/// silently swallowed (first-write-wins per Bundle A's hardening).
/// </summary>
/// <remarks>
/// <para>
/// Idempotency is the contract: a row that already exists at central counts
/// as "accepted" for the purposes of the reply, because the storage state is
/// consistent and the site is free to flip its local row to <c>Forwarded</c>.
/// </para>
/// <para>
/// Per Bundle D's brief, audit-write failures must NEVER abort the user-facing
/// action. The actor wraps each repository call in its own try/catch so a
/// single bad row cannot cause the rest of the batch to be lost — that
/// per-row catch is what keeps this actor alive across handler throws, not
/// the supervisor strategy. The <see cref="SupervisorStrategy"/> override
/// returns the Akka default decider (Restart for most exceptions) and
/// governs children only; this actor has no children today, so the override
/// is a forward-compat placeholder.
/// </para>
/// <para>
/// Two constructors exist for a deliberate reason: Bundle D's tests inject a
/// concrete <see cref="IAuditLogRepository"/> against a per-test MSSQL fixture
/// (the only way to verify the IngestedAtUtc stamp + duplicate-key idempotency
/// end to end), while Bundle E's host wiring registers the actor as a cluster
/// singleton and must therefore resolve the repository — which is a scoped EF
/// Core service — from a fresh DI scope per message. Mirroring the Notification
/// Outbox actor's pattern.
/// </para>
/// </remarks>
public class AuditLogIngestActor : ReceiveActor
{
    private readonly IServiceProvider? _serviceProvider;
    private readonly IAuditLogRepository? _injectedRepository;
    private readonly ILogger<AuditLogIngestActor> _logger;

    /// <summary>
    /// Test-mode constructor — injects a concrete repository instance whose
    /// lifetime exceeds the test, so the actor reuses the same instance across
    /// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
    /// </summary>
    /// <param name="repository">Audit log repository instance shared across all messages.</param>
    /// <param name="logger">Logger for ingest diagnostics.</param>
    public AuditLogIngestActor(
        IAuditLogRepository repository,
        ILogger<AuditLogIngestActor> logger)
    {
        ArgumentNullException.ThrowIfNull(repository);
        ArgumentNullException.ThrowIfNull(logger);

        _injectedRepository = repository;
        _logger = logger;

        ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
        // The single-repository test ctor cannot service the M3 dual-write —
        // it has no SiteCalls repo and no DbContext. The handler still
        // registers (so callers don't dead-letter) but replies empty so the
        // test surface stays explicit about what this ctor supports.
        ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryWithoutDualWriteAsync);
    }

    /// <summary>
    /// Production constructor — resolves <see cref="IAuditLogRepository"/> from
    /// a fresh DI scope per message because the repository is a scoped EF Core
    /// service registered by <c>AddConfigurationDatabase</c>. The actor itself
    /// is a long-lived cluster singleton, so it cannot hold a scope across
    /// messages.
    /// </summary>
    /// <param name="serviceProvider">Root service provider used to open a fresh scope per message.</param>
    /// <param name="logger">Logger for ingest diagnostics.</param>
    public AuditLogIngestActor(
        IServiceProvider serviceProvider,
        ILogger<AuditLogIngestActor> logger)
    {
        ArgumentNullException.ThrowIfNull(serviceProvider);
        ArgumentNullException.ThrowIfNull(logger);

        _serviceProvider = serviceProvider;
        _logger = logger;

        ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
        ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryAsync);
    }

    /// <inheritdoc />
    protected override SupervisorStrategy SupervisorStrategy()
    {
        return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
            Akka.Actor.SupervisorStrategy.DefaultDecider);
    }

    private async Task OnIngestAsync(IngestAuditEventsCommand cmd)
    {
        // Sender is captured before the first await — Akka resets Sender
        // between message dispatches, so a post-await Tell would go to
        // DeadLetters.
        var replyTo = Sender;
        var nowUtc = DateTime.UtcNow;
        var accepted = new List<Guid>(cmd.Events.Count);

        // Resolve the repository for the whole batch — one DbContext per
        // message, mirroring NotificationOutboxActor. The injected-repository
        // mode (Bundle D tests) skips the scope entirely.
        // Bundle C (M5-T6): the IAuditRedactor is also resolved from the
        // per-message scope when one is available so the row is truncated +
        // redacted before InsertIfNotExistsAsync. The single-repository test
        // ctor has no service provider — it falls through with no redactor,
        // which preserves the small-payload assumptions baked into the
        // existing D2 fixtures.
        // AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
        // services (IAsyncDisposable DbContexts) dispose asynchronously
        // without blocking on sync Dispose() of pending connection cleanup.
        if (_injectedRepository is not null)
        {
            await IngestWithRepositoryAsync(_injectedRepository, redactor: null, failureCounter: null, cmd, nowUtc, accepted)
                .ConfigureAwait(false);
        }
        else
        {
            await using var scope = _serviceProvider!.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
            var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
            // M6 Bundle E (T8): central health counter is best-effort —
            // unregistered (test composition roots) means the per-row catch
            // simply logs without surfacing on the health dashboard.
            var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
            await IngestWithRepositoryAsync(repository, redactor, failureCounter, cmd, nowUtc, accepted)
                .ConfigureAwait(false);
        }

        replyTo.Tell(new IngestAuditEventsReply(accepted));
    }

    private async Task IngestWithRepositoryAsync(
        IAuditLogRepository repository,
        IAuditRedactor? redactor,
        ICentralAuditWriteFailureCounter? failureCounter,
        IngestAuditEventsCommand cmd,
        DateTime nowUtc,
        List<Guid> accepted)
    {
        foreach (var evt in cmd.Events)
        {
            try
            {
                // Stamp IngestedAtUtc here, not at the site. Bundle A's
                // repository hardening already swallows duplicate-key races,
                // so the same id arriving twice (site retry, reconciliation)
                // is a silent no-op.
                // Redact BEFORE the IngestedAtUtc stamp so the redacted
                // copy carries the central-side ingest timestamp. The redactor
                // is contract-bound to never throw. AuditLog-008: a null
                // redactor (test composition root, no IAuditRedactor
                // registered) now falls back to the SafeDefault rather than
                // pass-through, so HTTP header redaction always runs.
                // C3 transitional shim: IngestedAtUtc is a DetailsJson field on
                // the canonical record, so stamp it via the projection helper.
                var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
                var filtered = safeRedactor.Apply(evt);
                var ingested = AuditRowProjection.WithIngestedAtUtc(filtered, nowUtc);
                await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
                accepted.Add(evt.EventId);
            }
            catch (Exception ex)
            {
                // Per-row catch — one bad row never sinks the whole batch.
                // The row stays Pending at the site; the next drain retries.
                // M6 Bundle E (T8): bump the central health counter so a
                // sustained insert-throw failure surfaces on the dashboard.
                try { failureCounter?.Increment(); }
                catch { /* counter must never throw — defence in depth */ }
                _logger.LogError(ex,
                    "Failed to persist audit event {EventId} during batch ingest; row will be retried by the site.",
                    evt.EventId);
            }
        }
    }

    /// <summary>
    /// M3 dual-write handler. For every <see cref="CachedTelemetryEntry"/> the
    /// actor opens a fresh MS SQL transaction, inserts the AuditLog row
    /// idempotently AND upserts the SiteCalls row monotonically. Both succeed
    /// or both roll back, so the audit and operational mirrors never drift
    /// mid-row. The IngestedAtUtc stamp is unified between the two rows so a
    /// downstream join lines up cleanly.
    /// </summary>
    /// <remarks>
    /// Per-entry isolation — one entry's failed transaction does NOT abort
    /// other entries in the batch (each gets its own
    /// <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.BeginTransactionAsync"/>
    /// scope and a try/catch around it). Audit-write failure NEVER aborts the
    /// user-facing action — the site keeps the row Pending and retries on the
    /// next drain.
    /// </remarks>
    private async Task OnCachedTelemetryAsync(IngestCachedTelemetryCommand cmd)
    {
        var replyTo = Sender;
        var accepted = new List<Guid>(cmd.Entries.Count);

        try
        {
            await using var scope = _serviceProvider!.CreateAsyncScope();
            var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
            var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
            var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // Bundle C (M5-T6): resolve the redactor for the whole batch from
            // the scope; null = SafeDefault for test composition roots that
            // skip the redactor registration. The redactor is contract-bound to
            // never throw, so we can apply it inside the per-entry try
            // without risking an unbounded blast radius.
            var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
            // M6 Bundle E (T8): same best-effort central health counter as
            // the OnIngestAsync path — null on test composition roots that
            // skip the registration.
            var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();

            foreach (var entry in cmd.Entries)
            {
                try
                {
                    await using var tx = await dbContext.Database
                        .BeginTransactionAsync()
                        .ConfigureAwait(false);

                    // Stamp IngestedAtUtc on both rows from a single
                    // central-side instant so a join on the two tables sees
                    // matching timestamps (debugging convenience, not a
                    // correctness invariant).
                    var ingestedAt = DateTime.UtcNow;
                    // Redact the audit half BEFORE the dual-write — only the
                    // AuditLog row's payload columns are redactable; SiteCalls
                    // carries operational state only (status, retry count) and
                    // is left untouched. AuditLog-008: null redactor falls back
                    // to SafeDefault so header redaction always runs.
                    // C3 transitional shim: IngestedAtUtc is a DetailsJson field
                    // on the canonical record, so stamp it via the projection helper.
                    var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
                    var filteredAudit = safeRedactor.Apply(entry.Audit);
                    var auditStamped = AuditRowProjection.WithIngestedAtUtc(filteredAudit, ingestedAt);
                    var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };

                    await auditRepo.InsertIfNotExistsAsync(auditStamped)
                        .ConfigureAwait(false);
                    await siteCallRepo.UpsertAsync(siteCallStamped)
                        .ConfigureAwait(false);

                    await tx.CommitAsync().ConfigureAwait(false);
                    accepted.Add(entry.Audit.EventId);
                }
                catch (Exception ex)
                {
                    // Both rows rolled back via the disposing transaction. The
                    // EventId is NOT added to `accepted` so the site keeps its
                    // row Pending and retries on the next drain. Other entries
                    // in the batch continue with their own transactions.
                    // M6 Bundle E (T8): bump the central health counter so a
                    // sustained dual-write failure surfaces on the dashboard.
                    try { failureCounter?.Increment(); }
                    catch { /* counter must never throw — defence in depth */ }
                    _logger.LogError(
                        ex,
                        "Combined telemetry dual-write failed for AuditEvent {EventId} / TrackedOperationId {TrackedOpId}; rolled back.",
                        entry.Audit.EventId,
                        entry.SiteCall.TrackedOperationId);
                }
            }
        }
        catch (Exception ex)
        {
            // Resolving the scope itself threw (e.g. DI mis-wiring). Log and
            // reply with whatever we managed to accept (likely empty) — the
            // central singleton MUST stay alive.
            _logger.LogError(
                ex,
                "Combined telemetry batch ingest failed before per-entry processing.");
        }

        replyTo.Tell(new IngestCachedTelemetryReply(accepted));
    }

    /// <summary>
    /// Fallback handler installed on the single-repository test ctor — that
    /// ctor has no DbContext and no <see cref="ISiteCallAuditRepository"/>, so
    /// it cannot service the dual-write. Logs a warning and replies with an
    /// empty ack so callers fall through to their retry path.
    /// </summary>
    private Task OnCachedTelemetryWithoutDualWriteAsync(IngestCachedTelemetryCommand cmd)
    {
        _logger.LogWarning(
            "AuditLogIngestActor received IngestCachedTelemetryCommand on the single-repository ctor; dual-write requires the IServiceProvider ctor. Replying with empty ack ({Count} entries).",
            cmd.Entries.Count);
        Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
        return Task.CompletedTask;
    }
}
