using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;

namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;

public sealed class GalaxyHierarchyCacheTests : IDisposable
{
    private readonly List<string> _tempPaths = [];

    /// <summary>
    /// Verifies cache returns empty entry before any refresh occurs.
    /// </summary>
    [Fact]
    public void Current_BeforeAnyRefresh_ReturnsEmpty()
    {
        GalaxyDeployNotifier notifier = new();
        ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked"));
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider());

        GalaxyHierarchyCacheEntry entry = cache.Current;

        Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
        Assert.False(entry.HasData);
        Assert.Equal(0, entry.ObjectCount);
        Assert.Empty(entry.Objects);
    }

    /// <summary>
    /// Verifies cache marks unavailable and does not publish when the repository
    /// surface throws — the production trigger for this code path is a SQL
    /// connection failure, but it is fully covered by the cache's exception
    /// branch and does not require a real TCP probe from a unit test.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish()
    {
        GalaxyDeployNotifier notifier = new();
        ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
        ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
        GalaxyHierarchyCache cache = new(repository, notifier, clock);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
        Assert.Equal("Galaxy repository unreachable", cache.Current.LastError);
        Assert.Null(notifier.Latest);
        Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
        Assert.Equal(1, repository.GetLastDeployTimeCount);
        Assert.Equal(0, repository.GetHierarchyCount);
        Assert.Equal(0, repository.GetAttributesCount);
    }

    /// <summary>
    /// Verifies HasData returns true for healthy cache entries.
    /// </summary>
    [Fact]
    public void HasData_OnHealthyEntry_IsTrue()
    {
        GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            LastSuccessAt = DateTimeOffset.UtcNow,
            ObjectCount = 1,
        };

        Assert.True(entry.HasData);
    }

    /// <summary>
    /// Verifies HasData returns false for unknown cache entries.
    /// </summary>
    [Fact]
    public void HasData_OnUnknownEntry_IsFalse()
    {
        Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
    }

    [Fact]
    public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
    {
        GalaxyObject root = new()
        {
            GobjectId = 1,
            TagName = "Area1",
            ContainedName = "Area1",
        };
        GalaxyObject duplicate = new()
        {
            GobjectId = 1,
            TagName = "DuplicateArea",
            ContainedName = "DuplicateArea",
        };
        GalaxyObject child = new()
        {
            GobjectId = 2,
            ParentGobjectId = 1,
            TagName = "Pump_001",
            ContainedName = "Pump",
            Attributes =
            {
                new GalaxyAttribute
                {
                    FullTagReference = "Pump_001.PV",
                    IsHistorized = true,
                },
            },
        };
        GalaxyObject orphan = new()
        {
            GobjectId = 3,
            ParentGobjectId = 99,
            TagName = "Orphan_001",
            ContainedName = "Orphan",
        };

        GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);

        Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
        Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
        Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
        Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
        Assert.Same(root, index.ObjectViewsById[1].Object);
    }

    /// <summary>
    ///     Verifies a successful refresh writes the browse dataset to the on-disk
    ///     snapshot store so a later cold start can restore it.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenSuccessful_PersistsSnapshotToDisk()
    {
        GalaxyDeployNotifier notifier = new();
        StubGalaxyRepository repository = new(
            deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
            hierarchy: [SampleHierarchyRow()],
            attributes: [SampleAttributeRow()]);
        GalaxyHierarchySnapshotStore store = CreateStore();
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
        GalaxyHierarchySnapshot? persisted = await store.TryLoadAsync(CancellationToken.None);
        Assert.NotNull(persisted);
        Assert.Equal(99, Assert.Single(persisted.Hierarchy).GobjectId);
        Assert.Equal("PV", Assert.Single(persisted.Attributes).AttributeName);
    }

    /// <summary>
    ///     Verifies that when the Galaxy database is unreachable on first refresh but a
    ///     snapshot exists on disk, the cache serves that data with <c>Stale</c> status
    ///     rather than coming up empty.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenDatabaseUnreachableButSnapshotOnDisk_RestoresStaleData()
    {
        GalaxyHierarchySnapshotStore store = CreateStore();
        await store.SaveAsync(
            new GalaxyHierarchySnapshot(
                LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero),
                SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
                Hierarchy: [SampleHierarchyRow()],
                Attributes: [SampleAttributeRow()]),
            CancellationToken.None);

        GalaxyDeployNotifier notifier = new();
        ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
        Assert.True(cache.Current.HasData);
        Assert.Equal(1, cache.Current.ObjectCount);
        Assert.Equal(1, cache.Current.AttributeCount);
        Assert.NotNull(notifier.Latest);
    }

    /// <summary>
    ///     Verifies that when the disk snapshot's deploy time still matches the live
    ///     Galaxy database, the cache promotes the restored data to <c>Healthy</c>
    ///     without re-running the heavy hierarchy and attribute queries.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenSnapshotDeployMatchesLive_PromotesToHealthyWithoutHeavyQuery()
    {
        DateTime deployTime = new(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
        GalaxyHierarchySnapshotStore store = CreateStore();
        await store.SaveAsync(
            new GalaxyHierarchySnapshot(
                LastDeployTime: new DateTimeOffset(deployTime, TimeSpan.Zero),
                SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
                Hierarchy: [SampleHierarchyRow()],
                Attributes: [SampleAttributeRow()]),
            CancellationToken.None);

        GalaxyDeployNotifier notifier = new();
        StubGalaxyRepository repository = new(deployTime);
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
        Assert.Equal(1, cache.Current.ObjectCount);
        Assert.Equal(0, repository.GetHierarchyCount);
        Assert.Equal(0, repository.GetAttributesCount);
    }

    /// <summary>
    ///     Verifies that a restored on-disk snapshot completes the first-load gate
    ///     immediately, so a browse call racing the first refresh is not blocked for
    ///     the full bootstrap budget while the live Galaxy query is still running.
    ///     Regression test for Server-033.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_RestoredSnapshotCompletesFirstLoadBeforeLiveQueryReturns()
    {
        GalaxyHierarchySnapshotStore store = CreateStore();
        await store.SaveAsync(
            new GalaxyHierarchySnapshot(
                LastDeployTime: new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero),
                SavedAt: new DateTimeOffset(2026, 5, 20, 9, 1, 0, TimeSpan.Zero),
                Hierarchy: [SampleHierarchyRow()],
                Attributes: [SampleAttributeRow()]),
            CancellationToken.None);

        GalaxyDeployNotifier notifier = new();
        BlockingGalaxyRepository repository = new();
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        Task refresh = cache.RefreshAsync(CancellationToken.None);

        // The live query is blocked inside the repository; first-load must still
        // complete — from the restored snapshot — well within the wait budget.
        await cache.WaitForFirstLoadAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
        Assert.True(cache.Current.HasData);
        Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);

        repository.Release();
        await refresh.WaitAsync(TimeSpan.FromSeconds(5));
    }

    /// <summary>
    ///     Verifies a corrupt on-disk snapshot does not crash startup: the cache
    ///     ignores the unreadable file and comes up Unavailable when the database is
    ///     also unreachable. Regression test for Server-037.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenSnapshotFileCorrupt_ComesUpUnavailableWithoutThrowing()
    {
        string path = CreateTempPath();
        await File.WriteAllTextAsync(path, "{ this is not valid json");
        GalaxyHierarchySnapshotStore store = CreateStore(path);

        GalaxyDeployNotifier notifier = new();
        ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
        Assert.False(cache.Current.HasData);
    }

    /// <summary>
    ///     Verifies that with snapshot persistence disabled the cache does not
    ///     restore from disk — an unreachable database leaves it Unavailable.
    ///     Regression test for Server-037.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenPersistDisabled_DoesNotRestoreFromDisk()
    {
        GalaxyHierarchySnapshotStore store = CreateStore(CreateTempPath(), persist: false);

        GalaxyDeployNotifier notifier = new();
        ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), snapshotStore: store);

        await cache.RefreshAsync(CancellationToken.None);

        Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
        Assert.False(cache.Current.HasData);
    }

    /// <summary>
    ///     Verifies that a snapshot save aborted because the gateway is shutting down
    ///     (the refresh token is cancelled) is not logged as a persistence failure.
    ///     Regression test for Server-036.
    /// </summary>
    [Fact]
    public async Task RefreshAsync_WhenSnapshotSaveCancelledAtShutdown_DoesNotLogPersistFailure()
    {
        using CancellationTokenSource cts = new();
        GalaxyDeployNotifier notifier = new();
        StubGalaxyRepository repository = new(
            deployTime: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
            hierarchy: [SampleHierarchyRow()],
            attributes: [SampleAttributeRow()]);
        CancellingSaveStore store = new(cts);
        RecordingLogger<GalaxyHierarchyCache> logger = new();
        GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider(), logger, store);

        await cache.RefreshAsync(cts.Token);

        Assert.DoesNotContain(
            logger.Entries,
            entry => entry.Level == LogLevel.Warning
                && entry.Message.Contains("persist", StringComparison.OrdinalIgnoreCase));
    }

    private static GalaxyHierarchyRow SampleHierarchyRow() => new()
    {
        GobjectId = 99,
        TagName = "Pump_001",
        ContainedName = "Pump",
        BrowseName = "Pump",
        CategoryId = 10,
        TemplateChain = ["AppPump"],
    };

    private static GalaxyAttributeRow SampleAttributeRow() => new()
    {
        GobjectId = 99,
        TagName = "Pump_001",
        AttributeName = "PV",
        FullTagReference = "Pump_001.PV",
        MxDataType = 5,
        DataTypeName = "Float",
    };

    private string CreateTempPath()
    {
        string path = Path.Combine(
            Path.GetTempPath(),
            $"mxgw-galaxy-cache-test-{Guid.NewGuid():N}.json");
        _tempPaths.Add(path);
        return path;
    }

    private GalaxyHierarchySnapshotStore CreateStore() => CreateStore(CreateTempPath());

    private static GalaxyHierarchySnapshotStore CreateStore(string path, bool persist = true)
    {
        GalaxyRepositoryOptions options = new()
        {
            PersistSnapshot = persist,
            SnapshotCachePath = path,
        };
        return new GalaxyHierarchySnapshotStore(Options.Create(options));
    }

    /// <summary><see cref="IGalaxyRepository"/> whose deploy-time query blocks until released.</summary>
    private sealed class BlockingGalaxyRepository : IGalaxyRepository
    {
        private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);

        public void Release() => _release.TrySetResult();

        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);

        public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
        {
            await _release.Task.WaitAsync(ct).ConfigureAwait(false);
            throw new InvalidOperationException("Galaxy repository unreachable");
        }

        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
            => throw new InvalidOperationException("GetHierarchyAsync should not be reached");

        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
            => throw new InvalidOperationException("GetAttributesAsync should not be reached");
    }

    /// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
    private sealed class CancellingSaveStore(CancellationTokenSource cts) : IGalaxyHierarchySnapshotStore
    {
        public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
            => Task.FromResult<GalaxyHierarchySnapshot?>(null);

        public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
        {
            cts.Cancel();
            cancellationToken.ThrowIfCancellationRequested();
            return Task.CompletedTask;
        }
    }

    /// <summary>Minimal <see cref="ILogger{T}"/> that records every emitted log entry.</summary>
    private sealed class RecordingLogger<T> : ILogger<T>
    {
        public List<(LogLevel Level, string Message)> Entries { get; } = [];

        public IDisposable BeginScope<TState>(TState state)
            where TState : notnull => NullScope.Instance;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel,
            EventId eventId,
            TState state,
            Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            Entries.Add((logLevel, formatter(state, exception)));
        }

        private sealed class NullScope : IDisposable
        {
            public static readonly NullScope Instance = new();

            public void Dispose()
            {
            }
        }
    }

    /// <summary>In-memory <see cref="IGalaxyRepository"/> that returns fixed rowsets.</summary>
    private sealed class StubGalaxyRepository(
        DateTime? deployTime,
        List<GalaxyHierarchyRow>? hierarchy = null,
        List<GalaxyAttributeRow>? attributes = null) : IGalaxyRepository
    {
        private readonly List<GalaxyHierarchyRow> _hierarchy = hierarchy ?? [];
        private readonly List<GalaxyAttributeRow> _attributes = attributes ?? [];

        public int GetHierarchyCount { get; private set; }

        public int GetAttributesCount { get; private set; }

        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);

        public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) => Task.FromResult(deployTime);

        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
        {
            GetHierarchyCount++;
            return Task.FromResult(_hierarchy);
        }

        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
        {
            GetAttributesCount++;
            return Task.FromResult(_attributes);
        }
    }

    public void Dispose()
    {
        foreach (string path in _tempPaths)
        {
            try
            {
                File.Delete(path);
                File.Delete(path + ".tmp");
            }
            catch (IOException)
            {
                // Best-effort cleanup of test scratch files.
            }
        }
    }

    private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
    {
        /// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
        public int GetLastDeployTimeCount { get; private set; }

        /// <summary>Gets the number of times <see cref="GetHierarchyAsync"/> was called.</summary>
        public int GetHierarchyCount { get; private set; }

        /// <summary>Gets the number of times <see cref="GetAttributesAsync"/> was called.</summary>
        public int GetAttributesCount { get; private set; }

        /// <inheritdoc />
        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);

        /// <inheritdoc />
        public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
        {
            GetLastDeployTimeCount++;
            throw toThrow;
        }

        /// <inheritdoc />
        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
        {
            GetHierarchyCount++;
            throw toThrow;
        }

        /// <inheritdoc />
        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
        {
            GetAttributesCount++;
            throw toThrow;
        }
    }

}
