using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;

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

/// <summary>
/// Unit tests for the central-side startup-reconciliation handler
/// (<see cref="ReconcileService"/>). A site node reports its local inventory; the
/// service diffs it against central's expected deployed set, stages fresh fetch
/// tokens for the gap (missing/stale), and reports orphans.
/// </summary>
public class ReconcileServiceTests
{
    private const string BaseUrl = "https://central.example:9000";
    private const string SiteIdentifier = "site1";
    private const int SiteId = 7;

    private readonly IDeploymentManagerRepository _deploymentRepo =
        Substitute.For<IDeploymentManagerRepository>();
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();

    private ReconcileService CreateService(TimeSpan? ttl = null)
    {
        var options = Options.Create(new CommunicationOptions
        {
            CentralFetchBaseUrl = BaseUrl,
            PendingDeploymentTtl = ttl ?? TimeSpan.FromMinutes(5),
        });
        return new ReconcileService(
            _deploymentRepo, _siteRepo, options, NullLogger<ReconcileService>.Instance);
    }

    private void SiteResolves() =>
        _siteRepo.GetSiteByIdentifierAsync(SiteIdentifier, Arg.Any<CancellationToken>())
            .Returns(new Site("Site One", SiteIdentifier) { Id = SiteId });

    private static ExpectedDeployment Expected(int id, string name, string rev, string dep, bool enabled = true) =>
        new(id, name, rev, dep, enabled);

    private void ExpectedSet(params ExpectedDeployment[] expected) =>
        _deploymentRepo.GetExpectedDeploymentsForSiteAsync(SiteId, Arg.Any<CancellationToken>())
            .Returns(expected.ToList());

    private void SnapshotFor(ExpectedDeployment exp) =>
        _deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(exp.InstanceId, Arg.Any<CancellationToken>())
            .Returns(new DeployedConfigSnapshot(exp.DeploymentId, exp.RevisionHash, $"{{\"cfg\":\"{exp.InstanceUniqueName}\"}}"));

    private void StageReturns(bool result) =>
        _deploymentRepo.StagePendingIfAbsentAsync(
                Arg.Any<int>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
                Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
                Arg.Any<CancellationToken>())
            .Returns(result);

    private static ReconcileSiteRequest Request(params (string Name, string Rev)[] local) =>
        new(SiteIdentifier, "node-a",
            local.ToDictionary(x => x.Name, x => x.Rev));

    [Fact]
    public async Task Reconcile_GapIsMissingAndStale_CurrentOmitted_WithFreshTokensAndSnapshotDeploymentIds()
    {
        SiteResolves();
        var a = Expected(1, "inst-A", "rev1", "dep-A");
        var b = Expected(2, "inst-B", "rev2", "dep-B");
        var c = Expected(3, "inst-C", "rev3", "dep-C", enabled: false);
        ExpectedSet(a, b, c);
        SnapshotFor(b);
        SnapshotFor(c);
        StageReturns(true);

        // Node has A current, B stale (revOLD), C missing entirely.
        var response = await CreateService().ReconcileAsync(
            Request(("inst-A", "rev1"), ("inst-B", "revOLD")));

        Assert.Equal(BaseUrl, response.CentralFetchBaseUrl);
        Assert.Equal(2, response.Gap.Count);
        Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-A");

        var gapB = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-B");
        Assert.Equal("dep-B", gapB.DeploymentId);
        Assert.Equal("rev2", gapB.RevisionHash);
        Assert.True(gapB.IsEnabled);
        Assert.False(string.IsNullOrWhiteSpace(gapB.FetchToken));

        var gapC = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-C");
        Assert.Equal("dep-C", gapC.DeploymentId);
        Assert.Equal("rev3", gapC.RevisionHash);
        Assert.False(gapC.IsEnabled);
        Assert.False(string.IsNullOrWhiteSpace(gapC.FetchToken));

        // Fresh, distinct tokens per gap item.
        Assert.NotEqual(gapB.FetchToken, gapC.FetchToken);
        Assert.Empty(response.OrphanNames);
    }

    [Fact]
    public async Task Reconcile_LocalNameNotInExpected_IsReportedAsOrphan()
    {
        SiteResolves();
        var a = Expected(1, "inst-A", "rev1", "dep-A");
        ExpectedSet(a);
        StageReturns(true);

        // inst-A is current; inst-Z is not deployed centrally → orphan.
        var response = await CreateService().ReconcileAsync(
            Request(("inst-A", "rev1"), ("inst-Z", "revX")));

        Assert.Empty(response.Gap);
        var orphan = Assert.Single(response.OrphanNames);
        Assert.Equal("inst-Z", orphan);
    }

    [Fact]
    public async Task Reconcile_StagePendingReturnsFalse_ExistingRowPresent_ReturnsExistingTokenInGap()
    {
        // Live-found bug: two site nodes concurrently missing the SAME instance both reconcile at
        // startup. The first stages a pending row; the second's StagePendingIfAbsent returns false.
        // The second node must STILL heal — the handler reads the existing pending row and returns
        // ITS deploymentId/revHash/token (the fetch token is multi-use within its TTL).
        SiteResolves();
        var b = Expected(2, "inst-B", "rev2", "dep-B");
        var c = Expected(3, "inst-C", "rev3", "dep-C");
        ExpectedSet(b, c);
        SnapshotFor(b);
        SnapshotFor(c);

        // B stages fresh (true). C already has a pending row (a concurrent reconcile from the
        // other node, or an in-flight deploy, staged it first) → stage returns false.
        StageReturns(true);
        _deploymentRepo.StagePendingIfAbsentAsync(
                3, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
                Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
                Arg.Any<CancellationToken>())
            .Returns(false);

        // The existing pending row for inst-C carries the FIRST node's deploymentId + token.
        var nowUtc = DateTimeOffset.UtcNow;
        _deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
            .Returns(new PendingDeployment(
                "dep-C-existing", 3, "rev3-existing", "{\"cfg\":\"inst-C\"}",
                "tok-C-existing", nowUtc, nowUtc.AddMinutes(5)));

        var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));

        Assert.Equal(2, response.Gap.Count);

        var gapB = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-B");
        Assert.Equal("dep-B", gapB.DeploymentId);

        // inst-C IS included, carrying the EXISTING row's deploymentId/revHash/token — NOT the
        // snapshot's (snapshot.DeploymentId would be "dep-C").
        var gapC = Assert.Single(response.Gap, g => g.InstanceUniqueName == "inst-C");
        Assert.Equal("dep-C-existing", gapC.DeploymentId);
        Assert.Equal("rev3-existing", gapC.RevisionHash);
        Assert.Equal("tok-C-existing", gapC.FetchToken);
        Assert.True(gapC.IsEnabled);
    }

    [Fact]
    public async Task Reconcile_StagePendingReturnsFalse_NoExistingRow_OmitsThatGapItem()
    {
        // Fallback path: stage returns false, but the pending row raced away (was purged between
        // the stage attempt and the read). Omit the item — the node retries on the next reconcile.
        SiteResolves();
        var b = Expected(2, "inst-B", "rev2", "dep-B");
        var c = Expected(3, "inst-C", "rev3", "dep-C");
        ExpectedSet(b, c);
        SnapshotFor(b);
        SnapshotFor(c);

        StageReturns(true);
        _deploymentRepo.StagePendingIfAbsentAsync(
                3, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
                Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
                Arg.Any<CancellationToken>())
            .Returns(false);

        // The pending row was purged between the stage attempt and the read.
        _deploymentRepo.GetPendingDeploymentByInstanceIdAsync(3, Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
            .Returns((PendingDeployment?)null);

        var response = await CreateService().ReconcileAsync(Request(/* empty local inventory */));

        var gap = Assert.Single(response.Gap);
        Assert.Equal("inst-B", gap.InstanceUniqueName);
        Assert.DoesNotContain(response.Gap, g => g.InstanceUniqueName == "inst-C");
    }

    [Fact]
    public async Task Reconcile_SnapshotMissing_SkipsGapItem()
    {
        SiteResolves();
        var b = Expected(2, "inst-B", "rev2", "dep-B");
        ExpectedSet(b);
        // No snapshot configured for inst-B → repo returns null (deleted race).
        StageReturns(true);

        var response = await CreateService().ReconcileAsync(Request());

        Assert.Empty(response.Gap);
        Assert.Empty(response.OrphanNames);
    }

    [Fact]
    public async Task Reconcile_UnknownSite_ReturnsEmptyResponse_NoThrow()
    {
        _siteRepo.GetSiteByIdentifierAsync(SiteIdentifier, Arg.Any<CancellationToken>())
            .Returns((Site?)null);

        var response = await CreateService().ReconcileAsync(
            Request(("inst-A", "rev1")));

        Assert.Empty(response.Gap);
        Assert.Empty(response.OrphanNames);
        Assert.Equal(BaseUrl, response.CentralFetchBaseUrl);
        await _deploymentRepo.DidNotReceive()
            .GetExpectedDeploymentsForSiteAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
    }
}
