using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;

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

/// <summary>
///     Unit tests for <see cref="AlarmWatchListResolver"/>: discovery/config merge,
///     subtag-address composition, canonical reference shaping, and the
///     unavailable-discovery code path.
/// </summary>
public sealed class AlarmWatchListResolverTests
{
    private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) =>
        new(repository, NullLogger<AlarmWatchListResolver>.Instance);

    private static AlarmsOptions Options(
        bool useGalaxyRepository = true,
        string area = "",
        string defaultArea = "",
        string[]? include = null,
        string[]? exclude = null,
        AlarmSubtagNameOptions? subtags = null) =>
        new()
        {
            DefaultArea = defaultArea,
            Fallback = new AlarmFallbackOptions
            {
                Discovery = new AlarmDiscoveryOptions
                {
                    UseGalaxyRepository = useGalaxyRepository,
                    Area = area,
                    IncludeAttributes = include ?? [],
                    ExcludeAttributes = exclude ?? [],
                },
                Subtags = subtags ?? new AlarmSubtagNameOptions(),
            },
        };

    /// <summary>
    ///     Verifies Galaxy Repository rows and config includes are unioned, configured
    ///     excludes are removed, and duplicate references (case-insensitive) collapse
    ///     to a single entry.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" },
            // Duplicate of an include below (case-insensitive) — should appear once.
            new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            include: ["pump01.fault", "Valve03.Position.Lo"],
            exclude: ["Tank02.Level.HiHi"]));

        Assert.Equal(
            new[] { "Tank01.Level.HiHi", "Pump01.Fault", "Valve03.Position.Lo" },
            result.Select(t => t.ActiveSubtag.Replace(".InAlarm", string.Empty, StringComparison.Ordinal)));
        // De-dup preserved first (GR) occurrence; exclude removed Tank02.
        Assert.Equal(3, result.Count);
    }

    /// <summary>Verifies subtag item addresses are composed using the configured subtag names.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            subtags: new AlarmSubtagNameOptions
            {
                Active = "InAlarm",
                Acked = "Ack",
                Priority = "Pri",
                AckComment = "AckCmt",
            }));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag);
        Assert.Equal("Tank01.Level.HiHi.Ack", target.AckedSubtag);
        Assert.Equal("Tank01.Level.HiHi.Pri", target.PrioritySubtag);
        Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag);
    }

    /// <summary>Verifies that configuring empty Priority and AckComment subtag names leaves those target fields empty.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        // Default Priority is "Priority"; force it empty alongside empty AckComment.
        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            subtags: new AlarmSubtagNameOptions { Priority = string.Empty, AckComment = string.Empty }));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag);
        Assert.Equal("Tank01.Level.HiHi.Acked", target.AckedSubtag);
        Assert.Equal(string.Empty, target.PrioritySubtag);
        Assert.Equal(string.Empty, target.AckCommentSubtag);
    }

    /// <summary>Verifies the canonical full reference uses the row's discovered Galaxy area, not the configured Discovery.Area.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea()
    {
        // The GR row carries the object's real Galaxy area (the alarmmgr group). The
        // composed reference must use that area, NOT the configured Discovery.Area.
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow
            {
                FullTagReference = "TestMachine_001.TestAlarm001",
                SourceObjectReference = "TestMachine_001",
                Area = "TestArea",
            },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        // Config area "DEV" must be ignored for a GR row that has a discovered area.
        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(
            Options(area: "DEV", defaultArea: "DEV"));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference);
    }

    /// <summary>Verifies the canonical full reference omits the area segment when neither a discovered nor a configured area is present.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea()
    {
        // GR row with no discovered area and no config area -> bare Galaxy!{reference}.
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        // No discovery area and no default area.
        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options());

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>Verifies a config include entry with no discovered area falls back to the configured Discovery.Area.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback()
    {
        // A config IncludeAttributes entry has no discovered area, so it uses the
        // config fallback: Discovery.Area when set.
        StubGalaxyRepository repo = new([]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            area: "Site_A",
            include: ["Tank01.Level.HiHi"]));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>Verifies a config include entry falls back to DefaultArea when Discovery.Area is empty.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty()
    {
        // A config IncludeAttributes entry with no Discovery.Area uses DefaultArea.
        StubGalaxyRepository repo = new([]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            area: "",
            defaultArea: "Plant",
            include: ["Tank01.Level.HiHi"]));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>Verifies that disabling Galaxy Repository discovery skips the repository call and resolves the watch list from configured includes only.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "ShouldNotAppear.X", SourceObjectReference = "ShouldNotAppear" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            useGalaxyRepository: false,
            include: ["Tank01.Level.HiHi"]));

        Assert.Equal(0, repo.GetAlarmAttributesCount);
        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>Verifies that a repository exception during discovery is logged and swallowed, returning the config-only watch list instead of throwing.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
    {
        ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable"));

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            include: ["Tank01.Level.HiHi"]));

        // Did not throw; discovery set empty, include retained.
        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>Verifies the source object reference is derived from a config include entry, using the portion before the first dot when present.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
    {
        StubGalaxyRepository repo = new([]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            include: ["Tank01.Level.HiHi", "StandaloneTag"]));

        Assert.Equal("Tank01", result[0].SourceObjectReference);
        // No dot — whole string is the source object.
        Assert.Equal("StandaloneTag", result[1].SourceObjectReference);
    }

    /// <summary>
    ///     Fix 1: ExcludeAttributes must be ignored when UseGalaxyRepository is false.
    ///     A config-only include must survive even when the same path appears in ExcludeAttributes.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ExcludeIgnored_WhenGalaxyRepositoryDisabled()
    {
        // Repo is never consulted; only IncludeAttributes matters.
        StubGalaxyRepository repo = new([]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            useGalaxyRepository: false,
            include: ["Tank01.Level.HiHi"],
            exclude: ["Tank01.Level.HiHi"]));

        // ExcludeAttributes is ignored when GR is off — the include must be present.
        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
    }

    /// <summary>
    ///     Fix 1 (GR-on path): ExcludeAttributes still prunes GR rows when
    ///     UseGalaxyRepository is true.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ExcludeApplied_WhenGalaxyRepositoryEnabled()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            useGalaxyRepository: true,
            exclude: ["Tank02.Level.HiHi"]));

        // Tank02 was excluded; only Tank01 remains.
        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal);
    }

    /// <summary>
    ///     Fix 1: A whitespace-only ExcludeAttributes entry must be skipped and must
    ///     not accidentally exclude any real reference.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_WhitespaceOnlyExcludeEntry_IsSkipped()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        // The exclude array contains a whitespace-only string — should be a no-op.
        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            useGalaxyRepository: true,
            exclude: ["   "]));

        // Tank01 must not have been wrongly excluded.
        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal);
    }

    /// <summary>
    ///     A cancellation triggered while Galaxy Repository discovery is
    ///     awaiting must propagate as <see cref="OperationCanceledException"/>, not be
    ///     swallowed into a config-only watch-list, per the <see cref="IAlarmWatchListResolver"/>
    ///     contract.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_RepositoryCancelled_PropagatesOperationCanceled()
    {
        using CancellationTokenSource cts = new();
        // Repository observes the token, cancels the source, then throws the matching
        // OperationCanceledException — exactly what the live SQL path does on shutdown.
        CancellingGalaxyRepository repo = new(cts);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            resolver.ResolveAsync(
                Options(include: ["Tank01.Level.HiHi"]),
                cts.Token));
    }

    /// <summary>
    ///     An entry that appears in both
    ///     <c>IncludeAttributes</c> and <c>ExcludeAttributes</c> is removed — excludes
    ///     win over explicit includes (the documented "excludes also suppress matching
    ///     explicit includes" behaviour).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ResolveAsync_ExcludeAlsoSuppressesMatchingExplicitInclude()
    {
        StubGalaxyRepository repo = new(
        [
            new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" },
        ]);

        AlarmWatchListResolver resolver = CreateResolver(repo);

        // Pump01.Fault is an explicit include AND an exclude (case-insensitive). It must
        // be dropped; the GR row Tank01 survives.
        IReadOnlyList<AlarmSubtagTarget> result = await resolver.ResolveAsync(Options(
            useGalaxyRepository: true,
            include: ["Pump01.Fault"],
            exclude: ["pump01.fault"]));

        AlarmSubtagTarget target = Assert.Single(result);
        Assert.Contains("Tank01", target.ActiveSubtag, StringComparison.Ordinal);
        Assert.DoesNotContain(result, t => t.ActiveSubtag.Contains("Pump01", StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>In-memory <see cref="IGalaxyRepository"/> returning a fixed alarm rowset.</summary>
    private sealed class StubGalaxyRepository(List<GalaxyAlarmAttributeRow> rows) : IGalaxyRepository
    {
        /// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
        public int GetAlarmAttributesCount { get; private set; }

        /// <summary>Always reports a successful connection.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="true"/>.</returns>
        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);

        /// <summary>Reports no known deploy time.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="null"/>.</returns>
        public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
            Task.FromResult<DateTime?>(null);

        /// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyHierarchyRow>());

        /// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyAttributeRow>());

        /// <summary>Returns the fixed alarm rowset supplied at construction, tracking how many times it was called.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying the configured alarm attribute rows.</returns>
        public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
        {
            GetAlarmAttributesCount++;
            return Task.FromResult(rows);
        }
    }

    /// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
    private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
    {
        /// <summary>Always reports a successful connection; only alarm-attribute discovery fails for this fake.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="true"/>.</returns>
        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);

        /// <summary>Reports no known deploy time.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="null"/>.</returns>
        public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
            Task.FromResult<DateTime?>(null);

        /// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyHierarchyRow>());

        /// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyAttributeRow>());

        /// <summary>Simulates the live SQL path failing by faulting with the configured exception.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A faulted task carrying the configured exception.</returns>
        public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
            Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
    }

    /// <summary>
    ///     <see cref="IGalaxyRepository"/> whose alarm-attribute query cancels the
    ///     supplied source and throws a token-bound <see cref="OperationCanceledException"/>,
    ///     mirroring the live SQL path being cancelled mid-await.
    /// </summary>
    private sealed class CancellingGalaxyRepository(CancellationTokenSource source) : IGalaxyRepository
    {
        /// <summary>Always reports a successful connection; only alarm-attribute discovery cancels for this fake.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="true"/>.</returns>
        public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);

        /// <summary>Reports no known deploy time.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying <see langword="null"/>.</returns>
        public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
            Task.FromResult<DateTime?>(null);

        /// <summary>Not exercised by these tests; returns an empty hierarchy.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyHierarchyRow>());

        /// <summary>Not exercised by these tests; returns an empty attribute set.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>A completed task carrying an empty list.</returns>
        public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
            Task.FromResult(new List<GalaxyAttributeRow>());

        /// <summary>Cancels the supplied source and throws, mirroring the live SQL path being cancelled mid-await.</summary>
        /// <param name="ct">Token to observe for cancellation.</param>
        /// <returns>Never returns normally; always throws <see cref="OperationCanceledException"/>.</returns>
        public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
        {
            source.Cancel();
            ct.ThrowIfCancellationRequested();
            return Task.FromResult(new List<GalaxyAlarmAttributeRow>());
        }
    }
}
