using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;

/// <summary>
/// arch-review UA2: dispatch-coverage guard. Every command type registered in
/// <see cref="ManagementCommandRegistry"/> must be reachable by a real dispatch arm
/// in <see cref="ManagementActor"/>. A command with no arm falls through to the
/// registry's default case, which throws <see cref="NotSupportedException"/>
/// ("Unknown command type"). This test proves that signature never occurs for any
/// registered command (bar the documented exclusion list), so a newly-added command
/// that someone forgot to wire up fails CI here rather than at runtime.
/// </summary>
/// <remarks>
/// Mechanism: for every registered command type we send a
/// <see cref="RuntimeHelpers.GetUninitializedObject"/> instance (all fields default —
/// we never execute the handler for real) inside an envelope carrying ALL roles, so
/// the authorization gate always passes. The actor runs on a service provider whose
/// scoped <see cref="IServiceProvider.GetService"/> throws a sentinel exception, so
/// any handler that touches the provider faults with the sentinel (a pass — the arm
/// exists). Only a command with NO arm faults with <see cref="NotSupportedException"/>.
/// A recording logger captures the exception <see cref="ManagementActor"/> maps in
/// its fault path, keyed by correlation id; commands that answer without ever
/// touching the provider (or throw some other fault) simply are not
/// <see cref="NotSupportedException"/> and therefore pass.
/// </remarks>
public class DispatchCoverageTests : TestKit, IDisposable
{
    /// <summary>
    /// Commands that are registered in <see cref="ManagementCommandRegistry"/> (so they
    /// carry a stable wire name across the management boundary) but are intentionally
    /// NOT dispatched by <see cref="ManagementActor"/> because they are serviced by a
    /// different actor path. Each entry is a deliberate design decision, not a missing arm.
    /// </summary>
    private static readonly HashSet<Type> Excluded = new()
    {
        // The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
        // resolves roles itself and sends a single envelope. Leaving a handler would
        // expose role-mapping data to a raw in-cluster sender with no role gate.
        // See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
        typeof(ResolveRolesCommand),

        // Site-routed, not a central ManagementActor command. Backs the CentralUI
        // "Test Bindings" dialog, which sends it straight through
        // CommunicationService.ReadTagValuesAsync (in-process) to the site's
        // DataConnectionManagerActor / DataConnectionActor — never through the
        // ManagementActor dispatch switch. It lives in the Messages.Management
        // namespace (hence auto-registered) only to earn a stable wire name for the
        // cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
        // ManagementActor arm would be the wrong transport for this command.
        typeof(ReadTagValuesCommand),
    };

    private static IEnumerable<Type> RegisteredCommandTypes() =>
        typeof(ManagementEnvelope).Assembly.GetTypes()
            .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
                        && t.Name.EndsWith("Command", StringComparison.Ordinal)
                        && !t.IsAbstract);

    void IDisposable.Dispose() => Shutdown();

    [Fact]
    public void EveryRegisteredCommand_ReachesADispatchArm()
    {
        var logger = new RecordingLogger<ManagementActor>();
        var actor = CreateSentinelActor(logger);

        var commandTypes = RegisteredCommandTypes()
            .Where(t => !Excluded.Contains(t))
            .OrderBy(t => t.Name, StringComparer.Ordinal)
            .ToList();

        // Sanity: the exclusion list must reference real, registered command types.
        foreach (var excluded in Excluded)
        {
            Assert.Contains(excluded, RegisteredCommandTypes());
        }

        var noDispatchArm = new List<string>();

        foreach (var type in commandTypes)
        {
            var command = RuntimeHelpers.GetUninitializedObject(type);
            var correlationId = Guid.NewGuid().ToString("N");
            var envelope = new ManagementEnvelope(
                new AuthenticatedUser("dispatch-probe", "Dispatch Probe", Roles.All, Array.Empty<string>()),
                command, correlationId);

            actor.Tell(envelope);

            // Barrier: every command produces exactly one response (Success / Error /
            // Unauthorized). By the time it arrives, MapFault has already logged any
            // fault, so the recording logger is populated for this correlation id.
            ExpectMsg<object>(TimeSpan.FromSeconds(10));

            if (logger.Captured.TryGetValue(correlationId, out var ex) && ex is NotSupportedException)
            {
                noDispatchArm.Add(type.Name);
            }
        }

        Assert.True(
            noDispatchArm.Count == 0,
            $"{noDispatchArm.Count} registered command(s) have no dispatch arm in ManagementActor " +
            $"(fell through to the NotSupportedException default). Add the missing arm, or — with " +
            $"justification — an entry to the exclusion list: {string.Join(", ", noDispatchArm)}");
    }

    /// <summary>
    /// Builds a <see cref="ManagementActor"/> over a service provider whose scoped
    /// <c>GetService</c> always throws <see cref="SentinelException"/>. The root
    /// provider still resolves <see cref="IServiceScopeFactory"/> so the actor's
    /// <c>CreateScope()</c> call succeeds and the fault surfaces only when a handler
    /// actually resolves a service.
    /// </summary>
    private IActorRef CreateSentinelActor(ILogger<ManagementActor> logger)
    {
        var scopedProvider = Substitute.For<IServiceProvider>();
        scopedProvider.GetService(Arg.Any<Type>()).Throws(_ => new SentinelException());

        var scope = Substitute.For<IServiceScope>();
        scope.ServiceProvider.Returns(scopedProvider);

        var scopeFactory = Substitute.For<IServiceScopeFactory>();
        scopeFactory.CreateScope().Returns(scope);

        var rootProvider = Substitute.For<IServiceProvider>();
        rootProvider.GetService(typeof(IServiceScopeFactory)).Returns(scopeFactory);

        return Sys.ActorOf(Props.Create(() => new ManagementActor(rootProvider, logger)));
    }

    /// <summary>Marker fault thrown by the scoped provider — distinct from the
    /// "no dispatch arm" <see cref="NotSupportedException"/> signature.</summary>
    private sealed class SentinelException : Exception
    {
        public SentinelException() : base("sentinel: scoped provider touched") { }
    }

    /// <summary>
    /// Minimal <see cref="ILogger{TCategoryName}"/> that records the exception passed
    /// to each error log, keyed by the <c>CorrelationId</c> structured field that
    /// <see cref="ManagementActor"/>'s fault path always includes.
    /// </summary>
    private sealed class RecordingLogger<T> : ILogger<T>
    {
        public ConcurrentDictionary<string, Exception> Captured { get; } = new();

        IDisposable? ILogger.BeginScope<TState>(TState state) => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
            Exception? exception, Func<TState, Exception?, string> formatter)
        {
            if (exception is null)
                return;

            var correlationId = ExtractCorrelationId(state);
            if (correlationId is not null)
                Captured[correlationId] = exception;
        }

        private static string? ExtractCorrelationId<TState>(TState state)
        {
            if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
            {
                foreach (var pair in pairs)
                {
                    if (pair.Key == "CorrelationId")
                        return pair.Value?.ToString();
                }
            }
            return null;
        }
    }
}
