using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;

/// <summary>
/// End-to-end multi-cluster scoping over the real 2-node Akka cluster: two driver nodes are bound
/// to DIFFERENT logical clusters (MAIN + SITE-A) via their <see cref="ClusterNode"/> rows, then a
/// SINGLE deployment is composed + broadcast through the real
/// <c>AdminOperationsActor → ConfigPublishCoordinator → DriverHostActor</c> path.
///
/// <para>This proves the full deploy path applies per-ClusterId scoping that the actor-level test
/// (<c>DriverHostActor_spawns_only_its_clusters_drivers</c>) covers in isolation: the node in MAIN
/// ends up hosting ONLY the MAIN driver, the node in SITE-A ONLY the SITE-A driver, and the
/// deployment still CONVERGES (seals) even though each node applies only a 1-driver slice — the ack
/// fires unconditionally regardless of slice size.</para>
///
/// <para>The harness wires <c>NullDriverFactory</c> (no real transports), so each spawned driver is
/// stubbed; we assert presence + identity (not connectivity) via
/// <see cref="IFleetDiagnosticsClient"/>, the same cross-node Ask path
/// <c>FleetDiagnosticsRoundTripTests</c> uses.</para>
/// </summary>
public sealed class MultiClusterScopingTests
{
    private const string MainCluster = "MAIN";
    private const string SiteACluster = "SITE-A";
    private const string MainDriverId = "main-modbus";
    private const string SiteADriverId = "sa-modbus";

    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    /// <summary>
    /// Verifies a single deploy scopes drivers per node: MAIN's node hosts only the MAIN driver,
    /// SITE-A's node only the SITE-A driver, and both nodes reach Applied so the deployment seals.
    /// </summary>
    [Fact]
    public async Task Deploy_scopes_drivers_to_each_nodes_own_cluster()
    {
        await using var harness = await TwoNodeClusterHarness.StartAsync();

        // Assign each cluster node to a DIFFERENT logical cluster. NodeId MUST equal the node's
        // ClusterRoleInfo.LocalNode (host:port) so the artifact's Nodes[] map resolves each node's
        // ClusterId at apply time.
        await SeedTwoClusterConfigAsync(harness, mainNodeId: harness.NodeANodeId, siteANodeId: harness.NodeBNodeId);

        await using var scope = harness.NodeA.Services.CreateAsyncScope();
        var adminOps = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();

        var result = await adminOps.StartDeploymentAsync(createdBy: "alice@test", Ct);
        result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
        var deploymentId = result.DeploymentId!.Value.Value;

        // Convergence: both nodes ack Applied and the coordinator seals — even though each node
        // applied only a 1-driver slice of the 2-cluster artifact.
        await WaitForAsync(async () =>
        {
            await using var db = await harness.CreateConfigDbContextAsync();
            var d = await db.Deployments.AsNoTracking()
                .FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, Ct);
            return d?.Status == DeploymentStatus.Sealed;
        }, TimeSpan.FromSeconds(20));

        await using (var db = await harness.CreateConfigDbContextAsync())
        {
            var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
                .Where(s => s.DeploymentId == deploymentId)
                .ToListAsync(Ct);
            nodeStates.Count.ShouldBe(2);
            nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
        }

        // Per-node driver presence: each DriverHostActor spawned ONLY its own cluster's slice.
        // Poll (rather than single-shot Ask) so transient timing after Sealed doesn't flake.
        var diagnostics = scope.ServiceProvider.GetRequiredService<IFleetDiagnosticsClient>();

        string[] mainDrivers = [];
        await WaitForAsync(async () =>
        {
            mainDrivers = await GetDriverNamesAsync(diagnostics, harness.NodeANodeId);
            return mainDrivers.SequenceEqual(new[] { MainDriverId });
        }, TimeSpan.FromSeconds(10));
        mainDrivers.ShouldBe(new[] { MainDriverId });

        string[] siteADrivers = [];
        await WaitForAsync(async () =>
        {
            siteADrivers = await GetDriverNamesAsync(diagnostics, harness.NodeBNodeId);
            return siteADrivers.SequenceEqual(new[] { SiteADriverId });
        }, TimeSpan.FromSeconds(10));
        siteADrivers.ShouldBe(new[] { SiteADriverId });
    }

    /// <summary>Asks a node's DriverHostActor (over the cluster) for the names of its spawned drivers.</summary>
    private static async Task<string[]> GetDriverNamesAsync(IFleetDiagnosticsClient diagnostics, string nodeId)
    {
        var snapshot = await diagnostics.GetDiagnosticsAsync(NodeId.Parse(nodeId), Ct);
        return snapshot.Drivers.Select(d => d.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
    }

    /// <summary>
    /// Seeds two single-node clusters (MAIN, SITE-A), one <see cref="ClusterNode"/> per cluster bound
    /// to the supplied <c>host:port</c> identities, and one <see cref="DriverInstance"/> per cluster.
    /// <see cref="ConfigComposer.SnapshotAndFlattenAsync"/> emits these straight into the artifact's
    /// <c>Clusters</c> / <c>Nodes</c> / <c>DriverInstances</c> arrays, giving each DriverHostActor the
    /// multi-cluster artifact <c>DeploymentArtifact.ResolveClusterScope</c> filters by NodeId.
    /// </summary>
    private static async Task SeedTwoClusterConfigAsync(TwoNodeClusterHarness harness, string mainNodeId, string siteANodeId)
    {
        await using var db = await harness.CreateConfigDbContextAsync();

        db.ServerClusters.AddRange(
            NewCluster(MainCluster, "Main Cluster", "central"),
            NewCluster(SiteACluster, "Site A Cluster", "site-a"));

        // v3: the Namespace entity is deleted (DriverInstance no longer binds a Namespace). Cluster
        // scoping filters by NodeId/ClusterId, so the namespace seed is dropped here.
        db.ClusterNodes.AddRange(
            NewNode(mainNodeId, MainCluster, "urn:zb:central:node-a"),
            NewNode(siteANodeId, SiteACluster, "urn:zb:site-a:node-b"));

        db.DriverInstances.AddRange(
            NewDriver(MainDriverId, MainCluster),
            NewDriver(SiteADriverId, SiteACluster));

        await db.SaveChangesAsync(Ct);
    }

    private static ServerCluster NewCluster(string clusterId, string name, string site) => new()
    {
        ClusterId = clusterId,
        Name = name,
        Enterprise = "zb",
        Site = site,
        NodeCount = 1,
        RedundancyMode = RedundancyMode.None,
        CreatedBy = "test",
    };

    private static ClusterNode NewNode(string nodeId, string clusterId, string applicationUri) => new()
    {
        NodeId = nodeId,
        ClusterId = clusterId,
        Host = TwoNodeClusterHarness.LoopbackHost,
        ApplicationUri = applicationUri,
        CreatedBy = "test",
    };

    private static DriverInstance NewDriver(string driverInstanceId, string clusterId) => new()
    {
        DriverInstanceId = driverInstanceId,
        ClusterId = clusterId,
        Name = driverInstanceId,
        DriverType = "Modbus",
        Enabled = true,
        DriverConfig = "{}",
    };

    private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
    {
        var deadline = DateTime.UtcNow + timeout;
        while (DateTime.UtcNow < deadline)
        {
            if (await condition()) return;
            await Task.Delay(200);
        }
        throw new TimeoutException($"Condition not met within {timeout}");
    }
}
