using ZB.MOM.WW.GalaxyRepository;

namespace ZB.MOM.WW.GalaxyRepository.Tests;

/// <summary>
///     Tests for <see cref="GalaxyHierarchyCache"/> first-load, deploy-gating, snapshot
///     restore, persistence, and status-transition behavior. Uses an in-memory
///     <see cref="IGalaxyRepository"/> and snapshot store plus a fixed
///     <see cref="StubTimeProvider"/> so no SQL is touched and no asserts are time-sensitive.
/// </summary>
public sealed class GalaxyHierarchyCacheTests
{
    private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
    private static readonly DateTime DeployTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);

    private static List<GalaxyHierarchyRow> SampleHierarchy() =>
    [
        new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", IsArea = true },
        new() { GobjectId = 2, TagName = "Pump01", ContainedName = "Pump01", BrowseName = "Pump01", ParentGobjectId = 1 },
    ];

    private static List<GalaxyAttributeRow> SampleAttributes() =>
    [
        new() { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true, IsAlarm = true },
    ];

    [Fact]
    public async Task RefreshAsync_FirstLoad_PopulatesCurrentWithDataAndUnblocksWaitForFirstLoad()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        RecordingDeployNotifier notifier = new();
        using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));

        // Before refresh, the gate is unset and there is no data.
        Assert.False(cache.Current.HasData);
        Assert.Equal(GalaxyCacheStatus.Unknown, cache.Current.Status);

        await cache.RefreshAsync(CancellationToken.None);

        // First load completes (does not hang) and Current now holds usable data.
        await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
        GalaxyHierarchyCacheEntry current = cache.Current;
        Assert.True(current.HasData);
        Assert.Equal(GalaxyCacheStatus.Healthy, current.Status);
        Assert.Equal(2, current.ObjectCount);
        Assert.Equal(1, current.AreaCount);
        Assert.Equal(1, current.AttributeCount);
        Assert.Equal(1, current.HistorizedAttributeCount);
        Assert.Equal(1, current.AlarmAttributeCount);

        // The heavy queries ran exactly once and a deploy event was published.
        Assert.Equal(1, repository.HierarchyReadCount);
        Assert.Equal(1, repository.AttributeReadCount);
        GalaxyDeployEventInfo published = Assert.Single(notifier.Published);
        Assert.Equal(2, published.ObjectCount);
        Assert.Equal(1, published.AttributeCount);
    }

    [Fact]
    public async Task RefreshAsync_NoDeployChange_SkipsHeavyQueriesOnSecondRefresh()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));

        await cache.RefreshAsync(CancellationToken.None);
        await cache.RefreshAsync(CancellationToken.None);

        // Deploy time unchanged => the heavy hierarchy/attribute reads happened only once.
        Assert.Equal(1, repository.HierarchyReadCount);
        Assert.Equal(1, repository.AttributeReadCount);
        Assert.True(cache.Current.HasData);
        Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
    }

    [Fact]
    public async Task RefreshAsync_DeployAdvances_RebuildsAndBumpsSequence()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        RecordingDeployNotifier notifier = new();
        using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));

        await cache.RefreshAsync(CancellationToken.None);
        long firstSequence = cache.Current.Sequence;

        repository.DeployTime = DeployTime.AddHours(1);
        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(2, repository.HierarchyReadCount);
        Assert.Equal(firstSequence + 1, cache.Current.Sequence);
        Assert.Equal(2, notifier.Published.Count);
    }

    [Fact]
    public async Task RefreshAsync_FirstQueryFailsNoPriorData_StatusUnavailableButFirstLoadStillCompletes()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
        {
            ThrowOnQuery = new TimeoutException("galaxy db unreachable"),
        };
        using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));

        await cache.RefreshAsync(CancellationToken.None);

        // First load must complete so callers do not hang, even though the query failed.
        await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
        Assert.False(cache.Current.HasData);
        Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
        Assert.Contains("unreachable", cache.Current.LastError);
    }

    [Fact]
    public async Task RefreshAsync_QueryFailsAfterPriorData_DegradesToStaleAndKeepsData()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));

        await cache.RefreshAsync(CancellationToken.None);
        Assert.True(cache.Current.HasData);

        // A later refresh fails: data is retained but flagged Stale.
        repository.DeployTime = DeployTime.AddHours(1);
        repository.ThrowOnQuery = new InvalidOperationException("transient");
        await cache.RefreshAsync(CancellationToken.None);

        Assert.True(cache.Current.HasData);
        Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
        Assert.Equal(2, cache.Current.ObjectCount);
    }

    [Fact]
    public async Task Current_AfterStalenessThreshold_ProjectsHealthyToStale()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        StubTimeProvider clock = new(FixedNow);
        using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), clock);

        await cache.RefreshAsync(CancellationToken.None);
        Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);

        // Advance past the 5-minute staleness threshold with no successful refresh.
        clock.Advance(TimeSpan.FromMinutes(6));

        Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
        // Data is still present — Stale means "old", not "gone".
        Assert.True(cache.Current.HasData);
    }

    [Fact]
    public async Task RefreshAsync_PersistsSnapshotAfterSuccessfulHeavyRefresh()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        FakeSnapshotStore store = new();
        using GalaxyHierarchyCache cache = new(
            repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(1, store.SaveCount);
        Assert.NotNull(store.Snapshot);
        Assert.Equal(2, store.Snapshot!.Hierarchy.Count);
        Assert.Single(store.Snapshot.Attributes);
    }

    [Fact]
    public async Task RefreshAsync_SnapshotRestore_ServesLastKnownDataAsStaleWhenDatabaseUnreachable()
    {
        // The snapshot store already holds a persisted dataset (last-known browse data).
        FakeSnapshotStore store = new()
        {
            Snapshot = new GalaxyHierarchySnapshot(
                LastDeployTime: DeployTime,
                SavedAt: FixedNow.AddMinutes(-1),
                Hierarchy: SampleHierarchy(),
                Attributes: SampleAttributes()),
        };

        // The Galaxy database is unreachable on this cold start.
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
        {
            ThrowOnQuery = new TimeoutException("cold start, db down"),
        };
        RecordingDeployNotifier notifier = new();
        using GalaxyHierarchyCache cache = new(
            repository, notifier, new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        // First load is satisfied by the restored snapshot, not by SQL.
        await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
        Assert.Equal(1, store.LoadCount);
        GalaxyHierarchyCacheEntry current = cache.Current;
        Assert.True(current.HasData);
        // Restored data is "last-known", surfaced as Stale until the live DB confirms.
        Assert.Equal(GalaxyCacheStatus.Stale, current.Status);
        Assert.Equal(2, current.ObjectCount);
        Assert.Equal(DeployTime, current.LastDeployTime!.Value.UtcDateTime);

        // A deploy event was published for the restored data.
        Assert.Single(notifier.Published);
    }

    [Fact]
    public async Task RefreshAsync_SnapshotRestoreThenLiveQuery_PromotesRestoredDataToHealthy()
    {
        FakeSnapshotStore store = new()
        {
            Snapshot = new GalaxyHierarchySnapshot(
                LastDeployTime: DeployTime,
                SavedAt: FixedNow.AddMinutes(-1),
                Hierarchy: SampleHierarchy(),
                Attributes: SampleAttributes()),
        };
        // DB is reachable and reports the SAME deploy time the snapshot was pulled at.
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        using GalaxyHierarchyCache cache = new(
            repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        // Restore seeds Stale data; the same-deploy live query promotes it to Healthy
        // without re-running the heavy hierarchy/attribute reads.
        Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
        Assert.Equal(0, repository.HierarchyReadCount);
        Assert.True(cache.Current.HasData);
    }

    [Fact]
    public void Dispose_CanBeCalledWithoutHavingRefreshed()
    {
        FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
        GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));

        // Dispose must be safe even when no refresh ever ran (semaphore never entered).
        cache.Dispose();
    }
}
