using System.Text.Json;
using Akka.Actor;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     Per-cluster mesh Phase 4 — a driver-only node runs with NO ConfigDb: <c>dbFactory</c> is null,
///     the redundant SQL ack-writes no-op (central persists the ack from the <c>ApplyAck</c>), and
///     scripted-alarm condition state is served from the replicated LocalDb store instead of the
///     ConfigDb-backed EF store. These tests drive a <c>FetchAndCache</c> actor with a null
///     <c>dbFactory</c> — the shape a real driver-only node boots in.
/// </summary>
public sealed class DriverHostActorDbLessTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));

    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), $"otopcua-dbless-{Guid.NewGuid():N}.db");
    private readonly ServiceProvider _localDbProvider;
    private readonly ILocalDb _localDb;

    public DriverHostActorDbLessTests()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
            .Build();

        _localDbProvider = new ServiceCollection()
            .AddZbLocalDb(configuration, db =>
            {
                using (var connection = db.CreateConnection())
                {
                    AlarmConditionStateSchema.Apply(connection);
                }

                db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
            })
            .BuildServiceProvider();

        _localDb = _localDbProvider.GetRequiredService<ILocalDb>();
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        if (!disposing) return;

        _localDbProvider.Dispose();
        SqliteConnection.ClearAllPools();
        foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
        {
            try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
        }
    }

    /// <summary>Builds a minimal but parseable artifact (no drivers) — the fetched-bytes stand-in.</summary>
    private static byte[] ArtifactBytes() =>
        JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = Array.Empty<object>() });

    private LocalDbAlarmConditionStateStore NewAlarmStore()
        => new(_localDb, NullLogger<LocalDbAlarmConditionStateStore>.Instance);

    private static ScriptRootLogger NewScriptRootLogger()
        => new(new LoggerConfiguration().CreateLogger());

    /// <summary>Resolves the named child of <paramref name="parent"/>, or null when it was not spawned.
    /// Safe to call once the parent has replied to an Identify — PreStart (and thus the spawn decision)
    /// completes before any message is processed.</summary>
    private IActorRef? ResolveChild(IActorRef parent, string name)
    {
        var id = Sys.ActorSelection(parent.Path / name)
            .Ask<ActorIdentity>(new Identify(name), TimeSpan.FromSeconds(3))
            .GetAwaiter().GetResult();
        return id.Subject;
    }

    /// <summary>Blocks until the actor has processed a message — proving PreStart has run.</summary>
    private void AwaitStarted(IActorRef actor)
    {
        var id = actor.Ask<ActorIdentity>(new Identify("started"), TimeSpan.FromSeconds(5))
            .GetAwaiter().GetResult();
        id.Subject.ShouldBe(actor);
    }

    /// <summary>
    ///     Task 4 — a full dispatch on a DB-less actor (null <c>dbFactory</c>, FetchAndCache, LocalDb
    ///     alarm store) applies and acks Applied without a NullReferenceException. The redundant SQL
    ///     ack-writes are guarded to no-ops; central is the ack system of record.
    /// </summary>
    [Fact]
    public void DbLessDispatch_AppliesAndAcks_WithNoConfigDb()
    {
        var cache = new RecordingArtifactCache();
        var fetcher = new RecordingFetcher(ArtifactBytes());
        var deploymentId = DeploymentId.NewId();
        var publish = CreateTestProbe();
        var coordinator = CreateTestProbe();

        var actor = Sys.ActorOf(DriverHostActor.Props(
            dbFactory: null,
            TestNode,
            coordinator.Ref,
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish.Ref,
            scriptRootLogger: NewScriptRootLogger(),
            deploymentArtifactCache: cache,
            fetchAndCacheMode: true,
            artifactFetcher: fetcher,
            alarmStateStore: NewAlarmStore()));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));

        coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        AwaitAssert(() => cache.Stores.Count.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
        fetcher.Calls.ShouldBe(1);
    }

    /// <summary>
    ///     Task 6 — a DB-less actor with a LocalDb alarm store spawns its ScriptedAlarm host (the store
    ///     is what makes the spawn possible without a ConfigDb).
    /// </summary>
    [Fact]
    public void DbLessNode_WithLocalDbStore_SpawnsScriptedAlarmHost()
    {
        var publish = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            dbFactory: null,
            TestNode,
            opcUaPublishActor: publish.Ref,
            scriptRootLogger: NewScriptRootLogger(),
            fetchAndCacheMode: true,
            artifactFetcher: new RecordingFetcher(ArtifactBytes()),
            alarmStateStore: NewAlarmStore()));

        AwaitStarted(actor);
        ResolveChild(actor, "scripted-alarm-host").ShouldNotBeNull();
    }

    /// <summary>
    ///     Task 6 — with neither a LocalDb store NOR a ConfigDb factory there is nowhere to persist
    ///     condition state, so the ScriptedAlarm host is skipped (not spawned against a null store).
    /// </summary>
    [Fact]
    public void DbLessNode_WithNoStoreAndNoDbFactory_SkipsScriptedAlarmHost()
    {
        var publish = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            dbFactory: null,
            TestNode,
            opcUaPublishActor: publish.Ref,
            scriptRootLogger: NewScriptRootLogger(),
            fetchAndCacheMode: true,
            artifactFetcher: new RecordingFetcher(ArtifactBytes()),
            alarmStateStore: null));

        AwaitStarted(actor);
        ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
    }

    /// <summary>
    ///     Phase 4 Task 9 — the ConfigDb-backed EF fallback is retired. A DB-backed actor (non-null
    ///     <c>dbFactory</c>) with no alarm store now SKIPS the ScriptedAlarm host: the wired
    ///     <c>IAlarmStateStore</c> is the only source of condition-state persistence, and there is no
    ///     longer an EF store constructed over <c>dbFactory</c>.
    /// </summary>
    [Fact]
    public void DbBackedNode_WithNoStore_SkipsScriptedAlarmHost()
    {
        var publish = CreateTestProbe();
        var actor = Sys.ActorOf(DriverHostActor.Props(
            dbFactory: NewInMemoryDbFactory(),
            TestNode,
            opcUaPublishActor: publish.Ref,
            scriptRootLogger: NewScriptRootLogger(),
            alarmStateStore: null));

        AwaitStarted(actor);
        ResolveChild(actor, "scripted-alarm-host").ShouldBeNull();
    }

    /// <summary>
    ///     Focused proof the LocalDb alarm store round-trips condition state with NO ConfigDb in play.
    /// </summary>
    [Fact]
    public async Task LocalDbAlarmStore_RoundTripsConditionState_WithoutConfigDb()
    {
        var store = NewAlarmStore();
        var t = new DateTime(2026, 07, 22, 08, 00, 00, DateTimeKind.Utc);
        var state = new AlarmConditionState(
            AlarmId: "dbless-alarm",
            Enabled: AlarmEnabledState.Enabled,
            Active: AlarmActiveState.Inactive,
            Acked: AlarmAckedState.Acknowledged,
            Confirmed: AlarmConfirmedState.Unconfirmed,
            Shelving: ShelvingState.Unshelved,
            LastTransitionUtc: t,
            LastActiveUtc: null,
            LastClearedUtc: null,
            LastAckUtc: t,
            LastAckUser: "op",
            LastAckComment: "ack",
            LastConfirmUtc: null,
            LastConfirmUser: null,
            LastConfirmComment: null,
            Comments: System.Collections.Immutable.ImmutableList<AlarmComment>.Empty);

        await store.SaveAsync(state, CancellationToken.None);
        var loaded = await store.LoadAsync("dbless-alarm", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
        loaded.LastAckUser.ShouldBe("op");
    }

    /// <summary>Records fetch calls and returns canned bytes (or null for "no answer").</summary>
    private sealed class RecordingFetcher(byte[]? bytes) : IDeploymentArtifactFetcher
    {
        private int _calls;
        public int Calls => Volatile.Read(ref _calls);

        public Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
        {
            Interlocked.Increment(ref _calls);
            return Task.FromResult(bytes);
        }
    }

    /// <summary>Records every store; GetCurrent* return null (no idempotency shortcut in these tests).</summary>
    private sealed class RecordingArtifactCache : IDeploymentArtifactCache
    {
        private readonly Lock _gate = new();
        private readonly List<StoreCall> _stores = [];

        public IReadOnlyList<StoreCall> Stores
        {
            get { lock (_gate) { return _stores.ToArray(); } }
        }

        public Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
                               byte[] artifact, CancellationToken ct = default)
        {
            lock (_gate) { _stores.Add(new StoreCall(clusterId, deploymentId, revisionHash, artifact)); }
            return Task.CompletedTask;
        }

        public Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default)
            => Task.FromResult<CachedDeploymentArtifact?>(null);

        public Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default)
            => Task.FromResult<CachedDeploymentArtifact?>(null);

        internal sealed record StoreCall(
            string ClusterId, string DeploymentId, string RevisionHash, byte[] Artifact);
    }
}
