using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;

/// <summary>
///     Verifies the config-gated <c>AddAlarmHistorian</c> registration: when the
///     <c>AlarmHistorian</c> section is absent or disabled the <see cref="NullAlarmHistorianSink"/>
///     default survives; when it is enabled a real <see cref="LocalDbStoreAndForwardSink"/> wins
///     (last-registration-wins over the <c>TryAddSingleton</c> Null default).
/// </summary>
public sealed class AlarmHistorianRegistrationTests
{
    /// <summary>A no-op writer the factory hands the sink; never actually invoked in these tests.</summary>
    private sealed class FakeWriter : IAlarmHistorianWriter
    {
        public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
            IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
            => Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
                batch.Select(_ => HistorianWriteOutcome.Ack).ToList());
    }

    /// <summary>Seed the Null default exactly the way <c>AddOtOpcUaRuntime</c> does, then add logging.</summary>
    private static ServiceCollection BaseServices()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
        return services;
    }

    private static IConfiguration ConfigFrom(Dictionary<string, string?> values)
        => new ConfigurationBuilder().AddInMemoryCollection(values).Build();

    [Fact]
    public void Section_absent_keeps_null_sink()
    {
        var services = BaseServices();
        var config = ConfigFrom(new Dictionary<string, string?>());

        services.AddAlarmHistorian(config, (_, _) => new FakeWriter());

        using var provider = services.BuildServiceProvider();
        provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<NullAlarmHistorianSink>();
    }

    [Fact]
    public void Section_disabled_keeps_null_sink()
    {
        var services = BaseServices();
        var config = ConfigFrom(new Dictionary<string, string?>
        {
            ["AlarmHistorian:Enabled"] = "false",
        });

        services.AddAlarmHistorian(config, (_, _) => new FakeWriter());

        using var provider = services.BuildServiceProvider();
        provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<NullAlarmHistorianSink>();
    }

    [Fact]
    public void Section_enabled_registers_the_localdb_sink()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(tempDir);
        var dbPath = Path.Combine(tempDir, "node-local.db");

        try
        {
            var config = ConfigFrom(new Dictionary<string, string?>
            {
                ["AlarmHistorian:Enabled"] = "true",
                ["LocalDb:Path"] = dbPath,
            });

            var services = BaseServices();
            services.AddZbLocalDb(config, db =>
            {
                using var connection = db.CreateConnection();
                AlarmSfSchema.Apply(connection);
                db.RegisterReplicated(AlarmSfSchema.EventsTable);
            });
            services.AddAlarmHistorian(config, (_, _) => new FakeWriter());

            using (var provider = services.BuildServiceProvider())
            {
                provider.GetRequiredService<IAlarmHistorianSink>().ShouldBeOfType<LocalDbStoreAndForwardSink>();
            } // dispose stops the drain loop + releases the SQLite file handle
        }
        finally
        {
            SqliteConnection.ClearAllPools();
            try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
        }
    }

    /// <summary>
    ///     A node whose LocalDb is not replicated drains its own queue regardless of redundancy role.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         Standing down is only safe because a peer holds the same rows and will send them
    ///         instead. Without <c>LocalDb:Replication:PeerAddress</c> there is no such peer: the rows
    ///         are here and nowhere else, so any deferral means nobody ever delivers them.
    ///     </para>
    ///     <para>
    ///         Found by the Phase 2 live gate. The redundancy role is a CLUSTER-WIDE election while the
    ///         queue is PAIR-LOCAL, and on the docker-dev rig the elected driver Primary turned out to
    ///         be a central node — one that carries the driver Akka role, replicates nobody's LocalDb
    ///         and does not even run the alarm historian. Every site node suspended its drain in favour
    ///         of a node that could not possibly deliver its events.
    ///     </para>
    /// </remarks>
    [Theory]
    // Neither key: an unpaired node. Its rows exist nowhere else.
    [InlineData(null, null, true)]
    // The dialing half of a pair — gated, because its partner holds the same rows.
    [InlineData("http://peer:9001", null, false)]
    // The LISTENING half. Same shared queue, but it never dials, so a PeerAddress-only test would
    // wrongly class it as unpaired and leave it permanently ungated.
    [InlineData(null, "9001", false)]
    public async Task Only_an_unreplicated_node_ignores_the_role_view(
        string? peerAddress, string? syncListenPort, bool expectDrain)
    {
        var tempDir = Path.Combine(Path.GetTempPath(), "otopcua-alarmhist-test-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(tempDir);
        var dbPath = Path.Combine(tempDir, "node-local.db");

        try
        {
            var config = ConfigFrom(new Dictionary<string, string?>
            {
                ["AlarmHistorian:Enabled"] = "true",
                ["LocalDb:Path"] = dbPath,
                ["LocalDb:Replication:PeerAddress"] = peerAddress,
                ["LocalDb:SyncListenPort"] = syncListenPort,
            });

            var services = BaseServices();
            services.AddZbLocalDb(config, db =>
            {
                using var connection = db.CreateConnection();
                AlarmSfSchema.Apply(connection);
                db.RegisterReplicated(AlarmSfSchema.EventsTable);
            });

            // A role view pinned SHUT: this node is a Secondary and a Primary exists elsewhere.
            services.AddSingleton<IRedundancyRoleView>(new StandDownRoleView());
            services.AddAlarmHistorian(config, (_, _) => new FakeWriter());

            using (var provider = services.BuildServiceProvider())
            {
                var sink = provider.GetRequiredService<IAlarmHistorianSink>();
                await sink.EnqueueAsync(SampleEvent, CancellationToken.None);

                await ((LocalDbStoreAndForwardSink)sink).DrainOnceAsync(CancellationToken.None);

                var status = sink.GetStatus();
                if (expectDrain)
                {
                    // Delivered and removed — not parked behind a gate that nobody else will open.
                    status.DrainState.ShouldNotBe(HistorianDrainState.NotPrimary);
                    status.QueueDepth.ShouldBe(0);
                }
                else
                {
                    // Held for the peer that shares this queue.
                    status.DrainState.ShouldBe(HistorianDrainState.NotPrimary);
                    status.QueueDepth.ShouldBe(1);
                }
            }
        }
        finally
        {
            SqliteConnection.ClearAllPools();
            try { Directory.Delete(tempDir, recursive: true); } catch (IOException) { /* best effort */ }
        }
    }

    /// <summary>A view that always says "some other node is the Primary".</summary>
    private sealed class StandDownRoleView : IRedundancyRoleView
    {
        public bool ShouldDrainAlarmHistory => false;

        public void Publish(bool shouldDrainAlarmHistory) { }
    }

    private static AlarmHistorianEvent SampleEvent => new(
        AlarmId: "eq/alarm-1",
        EquipmentPath: "line-1/eq-1",
        AlarmName: "temp-high",
        AlarmTypeName: "LimitAlarm",
        Severity: ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
        EventKind: "Activated",
        Message: "temperature high",
        User: "system",
        Comment: null,
        TimestampUtc: new DateTime(2026, 7, 21, 0, 0, 1, DateTimeKind.Utc));

    /// <summary>
    ///     An enabled historian with no LocalDb registered must fail at resolution, loudly.
    /// </summary>
    /// <remarks>
    ///     The queue has nowhere to live without it. Falling back to the Null sink here would look
    ///     like a working historian while silently discarding every alarm event — the failure mode
    ///     this whole registration is meant to make impossible.
    /// </remarks>
    [Fact]
    public void Section_enabled_without_localdb_throws_rather_than_silently_discarding()
    {
        var services = BaseServices();
        var config = ConfigFrom(new Dictionary<string, string?> { ["AlarmHistorian:Enabled"] = "true" });

        services.AddAlarmHistorian(config, (_, _) => new FakeWriter());

        using var provider = services.BuildServiceProvider();
        Should.Throw<InvalidOperationException>(() => provider.GetRequiredService<IAlarmHistorianSink>());
    }

    [Fact]
    public void Section_binds_drain_capacity_and_retention_knobs()
    {
        var config = ConfigFrom(new Dictionary<string, string?>
        {
            ["AlarmHistorian:Enabled"] = "true",
            ["AlarmHistorian:DrainIntervalSeconds"] = "11",
            ["AlarmHistorian:Capacity"] = "500",
            ["AlarmHistorian:DeadLetterRetentionDays"] = "7",
        });

        var opts = config.GetSection(AlarmHistorianOptions.SectionName).Get<AlarmHistorianOptions>();

        opts.ShouldNotBeNull();
        opts.DrainIntervalSeconds.ShouldBe(11);
        opts.Capacity.ShouldBe(500);
        opts.DeadLetterRetentionDays.ShouldBe(7);
    }

    [Fact]
    public void Validate_is_silent_when_correctly_configured()
    {
        new AlarmHistorianOptions { Enabled = true }.Validate().ShouldBeEmpty();
    }

    [Fact]
    public void Validate_is_silent_when_disabled()
    {
        new AlarmHistorianOptions { Enabled = false }.Validate().ShouldBeEmpty();
    }

    [Fact]
    public void Validate_warns_on_non_positive_drain_interval()
    {
        var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0 };
        opts.Validate().ShouldContain(w => w.Contains("DrainIntervalSeconds"));
    }

    [Fact]
    public void Validate_warns_on_non_positive_capacity()
    {
        var opts = new AlarmHistorianOptions { Enabled = true, Capacity = 0 };
        opts.Validate().ShouldContain(w => w.Contains("Capacity"));
    }

    [Fact]
    public void Validate_warns_on_non_positive_retention()
    {
        var opts = new AlarmHistorianOptions { Enabled = true, DeadLetterRetentionDays = 0 };
        opts.Validate().ShouldContain(w => w.Contains("DeadLetterRetentionDays"));
    }

    [Fact]
    public void Validate_warns_on_non_positive_max_attempts()
    {
        var opts = new AlarmHistorianOptions { Enabled = true, MaxAttempts = 0 };
        opts.Validate().ShouldContain(w => w.Contains("MaxAttempts"));
    }

    [Fact]
    public void Validate_accumulates_multiple_warnings()
    {
        // Two bad knobs ⇒ both warnings, not short-circuited on the first.
        var opts = new AlarmHistorianOptions { Enabled = true, DrainIntervalSeconds = 0, Capacity = 0 };
        var warnings = opts.Validate();
        warnings.ShouldContain(w => w.Contains("DrainIntervalSeconds"));
        warnings.ShouldContain(w => w.Contains("Capacity"));
        warnings.Count.ShouldBeGreaterThanOrEqualTo(2);
    }
}
