using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;

/// <summary>
/// Translates per-attempt
/// notifications from the store-and-forward retry loop into one (or two)
/// <see cref="CachedCallTelemetry"/> packets and pushes them through
/// <see cref="ICachedCallTelemetryForwarder"/>.
/// </summary>
/// <remarks>
/// <para>
/// The S&amp;F loop's <see cref="ICachedCallLifecycleObserver"/> reports a
/// single coarse outcome per attempt; the audit pipeline however models the
/// lifecycle as TWO rows on terminal outcomes — an <c>Attempted</c>
/// (<see cref="AuditKind.ApiCallCached"/> / <see cref="AuditKind.DbWriteCached"/>)
/// row capturing the per-attempt mechanics, plus a <see cref="AuditKind.CachedResolve"/>
/// row marking the terminal state for downstream consumers. The bridge fans
/// out per outcome:
/// </para>
/// <list type="bullet">
///   <item><description><c>TransientFailure</c> -> one Attempted(Failed) row.</description></item>
///   <item><description><c>Delivered</c> -> Attempted(Delivered) + CachedResolve(Delivered).</description></item>
///   <item><description><c>PermanentFailure</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
///   <item><description><c>ParkedMaxRetries</c> -> Attempted(Failed) + CachedResolve(Parked).</description></item>
/// </list>
/// <para>
/// <b>Best-effort emission (alog.md §7):</b> the bridge itself never throws;
/// the underlying forwarder swallows + logs its own failures.
/// </para>
/// </remarks>
public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
{
    private readonly ICachedCallTelemetryForwarder _forwarder;
    private readonly ILogger<CachedCallLifecycleBridge> _logger;

    /// <summary>
    /// SourceNode-stamping: the local node identity provider used to
    /// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call
    /// lifecycle row this bridge emits. Optional — when null (legacy hosts /
    /// tests that don't register the provider) SourceNode stays null and
    /// central persists the <c>SiteCalls</c> row with SourceNode NULL.
    /// </summary>
    private readonly INodeIdentityProvider? _nodeIdentity;

    /// <summary>Initializes a new <see cref="CachedCallLifecycleBridge"/> with the given telemetry forwarder, logger, and optional node identity provider.</summary>
    /// <param name="forwarder">The telemetry forwarder used to ship cached-call lifecycle events to central.</param>
    /// <param name="logger">Logger for bridge diagnostics.</param>
    /// <param name="nodeIdentity">Optional node identity provider used to stamp <c>SourceNode</c> on emitted telemetry rows.</param>
    public CachedCallLifecycleBridge(
        ICachedCallTelemetryForwarder forwarder,
        ILogger<CachedCallLifecycleBridge> logger,
        INodeIdentityProvider? nodeIdentity = null)
    {
        _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _nodeIdentity = nodeIdentity;
    }

    /// <inheritdoc/>
    public async Task OnAttemptCompletedAsync(
        CachedCallAttemptContext context, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(context);

        try
        {
            await EmitAttemptedAsync(context, ct).ConfigureAwait(false);

            if (IsTerminal(context.Outcome))
            {
                await EmitResolveAsync(context, ct).ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            // Defensive — both EmitX paths call the forwarder which is itself
            // best-effort. A throw here is unexpected, but the alog.md §7
            // contract requires we never propagate.
            _logger.LogWarning(ex,
                "CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})",
                context.TrackedOperationId, context.Outcome);
        }
    }

    private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct)
    {
        // Per-attempt row: kind discriminates channel; status is always
        // Attempted regardless of outcome (success vs. failure is captured
        // by the companion HttpStatus / ErrorMessage fields, NOT by flipping
        // the status — CachedResolve carries the terminal Status). Per
        // alog.md §4.
        var kind = ChannelToAttemptKind(context.Channel);
        var status = AuditStatus.Attempted;

        var packet = BuildPacket(
            context,
            kind: kind,
            status: status,
            // Operational status mirror — for the per-attempt row the
            // operational state is the running status; the bridge always
            // writes "Attempted" so reconciliation can't roll back.
            operationalStatus: "Attempted",
            terminalAtUtc: null,
            lastError: context.LastError,
            httpStatus: context.HttpStatus);

        await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
    }

    private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct)
    {
        var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome);

        var packet = BuildPacket(
            context,
            kind: AuditKind.CachedResolve,
            status: auditStatus,
            operationalStatus: operationalStatus,
            terminalAtUtc: context.OccurredAtUtc,
            lastError: context.LastError,
            httpStatus: context.HttpStatus);

        await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
    }

    private CachedCallTelemetry BuildPacket(
        CachedCallAttemptContext context,
        AuditKind kind,
        AuditStatus status,
        string operationalStatus,
        DateTime? terminalAtUtc,
        string? lastError,
        int? httpStatus)
    {
        var channel = ChannelStringToEnum(context.Channel);

        return new CachedCallTelemetry(
            Audit: ScadaBridgeAuditEventFactory.Create(
                channel: channel,
                kind: kind,
                status: status,
                occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
                target: context.Target,
                correlationId: context.TrackedOperationId.Value,
                // The originating script
                // execution's per-run correlation id, threaded through the S&F
                // buffer; null on rows buffered before ExecutionId support was added (back-compat).
                executionId: context.ExecutionId,
                // The spawning
                // inbound-API request's ExecutionId, threaded through the S&F
                // buffer alongside ExecutionId so the retry-loop cached rows
                // correlate back to the cross-execution chain. Null for a
                // non-routed run and on rows buffered before ParentExecutionId support was added.
                parentExecutionId: context.ParentExecutionId,
                sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
                sourceInstanceId: context.SourceInstanceId,
                // SourceScript is now
                // threaded through the S&F buffer alongside ExecutionId — the
                // retry-loop cached rows carry the same provenance the
                // script-side cached rows do. Null on rows buffered before ExecutionId support was added.
                sourceScript: context.SourceScript,
                httpStatus: httpStatus,
                durationMs: context.DurationMs,
                errorMessage: lastError),
            Operational: new SiteCallOperational(
                TrackedOperationId: context.TrackedOperationId,
                Channel: context.Channel,
                Target: context.Target,
                SourceSite: context.SourceSite,
                // SourceNode-stamping: the local cluster node name
                // (node-a/node-b on a site). Stamped from the injected
                // INodeIdentityProvider; null when no provider was wired so
                // central persists SiteCalls.SourceNode as NULL.
                SourceNode: _nodeIdentity?.NodeName,
                Status: operationalStatus,
                RetryCount: context.RetryCount,
                LastError: lastError,
                HttpStatus: httpStatus,
                CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc),
                UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
                TerminalAtUtc: terminalAtUtc is null
                    ? null
                    : DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc)));
    }

    private static AuditKind ChannelToAttemptKind(string channel) => channel switch
    {
        "ApiOutbound" => AuditKind.ApiCallCached,
        "DbOutbound" => AuditKind.DbWriteCached,
        // Defensive default — the S&F observer is filtered to cached-call
        // categories so this branch shouldn't fire in practice.
        _ => AuditKind.ApiCallCached,
    };

    private static AuditChannel ChannelStringToEnum(string channel) => channel switch
    {
        "ApiOutbound" => AuditChannel.ApiOutbound,
        "DbOutbound" => AuditChannel.DbOutbound,
        _ => AuditChannel.ApiOutbound,
    };

    private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses(
        CachedCallAttemptOutcome outcome) => outcome switch
    {
        CachedCallAttemptOutcome.Delivered =>
            (AuditStatus.Delivered, "Delivered"),
        CachedCallAttemptOutcome.PermanentFailure =>
            (AuditStatus.Parked, "Parked"),
        CachedCallAttemptOutcome.ParkedMaxRetries =>
            (AuditStatus.Parked, "Parked"),
        // TransientFailure isn't terminal — see IsTerminal — but the switch
        // is exhaustive so we route it through Failed for safety.
        CachedCallAttemptOutcome.TransientFailure =>
            (AuditStatus.Failed, "Failed"),
        _ => (AuditStatus.Failed, "Failed"),
    };

    private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch
    {
        CachedCallAttemptOutcome.Delivered => true,
        CachedCallAttemptOutcome.PermanentFailure => true,
        CachedCallAttemptOutcome.ParkedMaxRetries => true,
        CachedCallAttemptOutcome.TransientFailure => false,
        _ => false,
    };
}
