using Grpc.Core;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;

namespace ZB.MOM.WW.GalaxyRepository.Tests;

/// <summary>
///     Pure-logic tests for <see cref="GalaxyHierarchyProjector"/> and
///     <see cref="GalaxyBrowseProjector"/>. No SQL: the cache entry under test is built
///     from a small hand-made hierarchy through the same materialization the live cache
///     uses (a fake <see cref="IGalaxyRepository"/> driven through
///     <see cref="GalaxyHierarchyCache.RefreshAsync"/>), so the projectors are exercised
///     against a real <see cref="GalaxyHierarchyIndex"/>.
/// </summary>
public sealed class GalaxyHierarchyProjectorTests
{
    /// <summary>
    ///     Builds a realistic cache entry by driving a fake repository through the cache's
    ///     own refresh path. This goes through <c>BuildEntry</c> + <see cref="GalaxyHierarchyIndex.Build"/>
    ///     exactly as production does, rather than reaching for an internal factory.
    /// </summary>
    private static GalaxyHierarchyCacheEntry BuildEntry(
        IReadOnlyList<GalaxyHierarchyRow> hierarchy,
        IReadOnlyList<GalaxyAttributeRow> attributes)
    {
        FakeGalaxyRepository repository = new(hierarchy, attributes, deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
        using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier());
        cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult();
        GalaxyHierarchyCacheEntry entry = cache.Current;
        Assert.True(entry.HasData);
        return entry;
    }

    // A small but representative galaxy:
    //   PlantArea (area, id 1)
    //     ├─ LineA (area, id 2)
    //     │    ├─ Pump01 (id 10, template "Pump", historized+alarm attr)
    //     │    └─ Valve01 (id 11, template "Valve", plain attr)
    //     └─ Mixer01 (id 12, template "Mixer", alarm attr only)
    //   StandaloneTank (id 20, no parent — a root object)
    private static GalaxyHierarchyCacheEntry BuildSampleEntry()
    {
        List<GalaxyHierarchyRow> hierarchy =
        [
            Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100),
            Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100),
            Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump", "$UserDefined"]),
            Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]),
            Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]),
            Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]),
        ];

        List<GalaxyAttributeRow> attributes =
        [
            // Pump01: historized AND alarm-bearing.
            Attribute(10, "Pump01.PV", historized: true, alarm: true),
            Attribute(10, "Pump01.SP", historized: false, alarm: false),
            // Valve01: plain.
            Attribute(11, "Valve01.Cmd", historized: false, alarm: false),
            // Mixer01: alarm only.
            Attribute(12, "Mixer01.Fault", historized: false, alarm: true),
            // StandaloneTank: historized only.
            Attribute(20, "StandaloneTank.Level", historized: true, alarm: false),
        ];

        return BuildEntry(hierarchy, attributes);
    }

    private static GalaxyHierarchyRow Hierarchy(
        int id,
        string tagName,
        int parent,
        bool isArea = false,
        int category = 0,
        IReadOnlyList<string>? templates = null) => new()
        {
            GobjectId = id,
            TagName = tagName,
            ContainedName = tagName,
            BrowseName = tagName,
            ParentGobjectId = parent,
            IsArea = isArea,
            CategoryId = category,
            TemplateChain = templates ?? Array.Empty<string>(),
        };

    private static GalaxyAttributeRow Attribute(
        int gobjectId,
        string fullTagReference,
        bool historized,
        bool alarm) => new()
        {
            GobjectId = gobjectId,
            AttributeName = fullTagReference.Split('.')[^1],
            FullTagReference = fullTagReference,
            IsHistorized = historized,
            IsAlarm = alarm,
        };

    [Fact]
    public void Project_NoFilters_ReturnsEveryObject()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest());

        Assert.Equal(6, result.TotalObjectCount);
        Assert.Equal(6, result.Objects.Count);
    }

    [Fact]
    public void Project_PageSizeAndOffset_SlicesTheOrderedResult()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new();

        GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue);
        GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 2);
        GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 2, pageSize: 2);
        GalaxyHierarchyQueryResult page3 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 4, pageSize: 2);

        // Total is unaffected by paging.
        Assert.Equal(6, page1.TotalObjectCount);
        Assert.Equal(2, page1.Objects.Count);
        Assert.Equal(2, page2.Objects.Count);
        Assert.Equal(2, page3.Objects.Count);

        // The three pages reconstruct the full ordered result with no gaps/dupes.
        List<int> paged =
        [
            .. page1.Objects.Select(o => o.GobjectId),
            .. page2.Objects.Select(o => o.GobjectId),
            .. page3.Objects.Select(o => o.GobjectId),
        ];
        Assert.Equal(full.Objects.Select(o => o.GobjectId), paged);
    }

    [Fact]
    public void Project_OffsetPastEnd_ReturnsEmptyPageButRealTotal()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
            entry, new DiscoverHierarchyRequest(), browseSubtreeGlobs: null, offset: 999, pageSize: 10);

        Assert.Empty(result.Objects);
        Assert.Equal(6, result.TotalObjectCount);
    }

    [Fact]
    public void Project_PageSignature_IsStableAcrossPagesAndMatchesComputeFilterSignature()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };

        string expected = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null);
        GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 1);
        GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 1, pageSize: 1);

        // The signature a caller computes to mint a page token round-trips: the projector
        // reports the same signature on every page of the same filter set.
        Assert.Equal(expected, page1.FilterSignature);
        Assert.Equal(expected, page2.FilterSignature);
    }

    [Fact]
    public void ComputeFilterSignature_DiffersWhenAnyFilterChanges()
    {
        DiscoverHierarchyRequest baseRequest = new() { TagNameGlob = "Pump*" };
        DiscoverHierarchyRequest differentGlob = new() { TagNameGlob = "Valve*" };
        DiscoverHierarchyRequest differentAlarm = new() { TagNameGlob = "Pump*", AlarmBearingOnly = true };

        string baseSig = GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, null);

        Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentGlob, null));
        Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentAlarm, null));
        Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, browseSubtreeGlobs: ["PlantArea/*"]));
        // Same inputs => same signature (deterministic).
        Assert.Equal(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }, null));
    }

    [Fact]
    public void Project_MaxDepthZero_FromRoot_ReturnsOnlyTheRoot()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 0 };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        GalaxyObject only = Assert.Single(result.Objects);
        Assert.Equal(1, only.GobjectId);
    }

    [Fact]
    public void Project_MaxDepthOne_FromRoot_ReturnsRootAndDirectChildrenOnly()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        // PlantArea(1) depth 0; LineA(2) and Mixer01(12) depth 1; Pump01/Valve01 depth 2.
        DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 1 };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        Assert.Equal([1, 2, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
    }

    [Fact]
    public void Project_NegativeMaxDepth_ThrowsInvalidArgument()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { MaxDepth = -1 };

        RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
        Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
    }

    [Fact]
    public void Project_UnknownRoot_ThrowsNotFound()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { RootGobjectId = 99999 };

        RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
        Assert.Equal(StatusCode.NotFound, ex.StatusCode);
    }

    [Fact]
    public void Project_HistorizedOnly_ReturnsOnlyObjectsWithAHistorizedAttribute()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { HistorizedOnly = true };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        // Pump01(10) and StandaloneTank(20) carry historized attributes.
        Assert.Equal([10, 20], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
    }

    [Fact]
    public void Project_AlarmBearingOnly_ReturnsOnlyObjectsWithAnAlarmAttribute()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        // Pump01(10) and Mixer01(12) carry alarm attributes.
        Assert.Equal([10, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
    }

    [Fact]
    public void Project_AlarmAndHistorizedTogether_RequiresBoth()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true, HistorizedOnly = true };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        // Only Pump01(10) carries an attribute set that is both historized and alarm-bearing.
        GalaxyObject only = Assert.Single(result.Objects);
        Assert.Equal(10, only.GobjectId);
    }

    [Fact]
    public void Project_TagNameGlob_MatchesAnchoredCaseInsensitive()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();

        GalaxyHierarchyQueryResult prefix = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });
        Assert.Equal([10], prefix.Objects.Select(o => o.GobjectId));

        // Case-insensitive.
        GalaxyHierarchyQueryResult lower = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "pump01" });
        Assert.Equal([10], lower.Objects.Select(o => o.GobjectId));

        // '?' single-char wildcard: "Pump0?" matches "Pump01".
        GalaxyHierarchyQueryResult single = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump0?" });
        Assert.Equal([10], single.Objects.Select(o => o.GobjectId));

        // Anchored: a bare substring that is not a prefix matches nothing.
        GalaxyHierarchyQueryResult anchored = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "ump01" });
        Assert.Empty(anchored.Objects);
    }

    [Fact]
    public void Project_CategoryIds_FilterByObjectCategory()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { CategoryIds = { 200, 201 } };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        // category 200 = Pump01(10), category 201 = Valve01(11).
        Assert.Equal([10, 11], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
    }

    [Fact]
    public void Project_TemplateChainContains_IsSubstringAndCaseInsensitive()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { TemplateChainContains = { "pump" } };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        GalaxyObject only = Assert.Single(result.Objects);
        Assert.Equal(10, only.GobjectId);
    }

    [Fact]
    public void Project_IncludeAttributesDefault_CarriesAttributes()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        GalaxyObject pump = Assert.Single(result.Objects);
        Assert.Equal(2, pump.Attributes.Count);
    }

    [Fact]
    public void Project_IncludeAttributesFalse_ReturnsSkeletons()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*", IncludeAttributes = false };

        GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);

        GalaxyObject pump = Assert.Single(result.Objects);
        Assert.Empty(pump.Attributes);
    }

    [Fact]
    public void Project_IncludeAttributesFalse_DoesNotMutateTheCachedEntry()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();

        // Project with attributes stripped, then again with attributes included.
        GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*", IncludeAttributes = false });
        GalaxyHierarchyQueryResult included = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });

        // The earlier strip cloned the object — the cached entry still holds the attributes.
        GalaxyObject pump = Assert.Single(included.Objects);
        Assert.Equal(2, pump.Attributes.Count);
    }

    [Fact]
    public void Project_InvalidOffsetOrPageSize_Throws()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();

        Assert.Throws<ArgumentOutOfRangeException>(() =>
            GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: -1, pageSize: 10));
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: 0, pageSize: 0));
    }

    // ---- GalaxyBrowseProjector ----

    [Fact]
    public void ProjectChildren_OfPlantArea_ReturnsDirectChildrenAreasFirst()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        BrowseChildrenRequest request = new() { ParentGobjectId = 1 };

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);

        // Direct children of PlantArea(1) are LineA(2, area) and Mixer01(12, non-area);
        // areas sort first.
        Assert.Equal([2, 12], result.Children.Select(c => c.GobjectId));
        Assert.Equal(2, result.TotalChildCount);
    }

    [Fact]
    public void ProjectChildren_ChildHasChildrenFlag_ReflectsPresenceOfChildren()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        BrowseChildrenRequest request = new() { ParentGobjectId = 1 };

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);

        Dictionary<int, bool> hasChildren = result.Children
            .Select((child, index) => (child.GobjectId, result.ChildHasChildren[index]))
            .ToDictionary(t => t.GobjectId, t => t.Item2);

        // LineA(2) contains Pump01/Valve01 -> true; Mixer01(12) is a leaf -> false.
        Assert.True(hasChildren[2]);
        Assert.False(hasChildren[12]);
    }

    [Fact]
    public void ProjectChildren_OfRoot_ReturnsTopLevelObjects()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        // Empty parent oneof => roots (parent id 0).
        BrowseChildrenRequest request = new();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);

        // Roots: PlantArea(1, area) and StandaloneTank(20, non-area); areas first.
        Assert.Equal([1, 20], result.Children.Select(c => c.GobjectId));
    }

    [Fact]
    public void ProjectChildren_FilterMatchingDescendant_SurfacesNonMatchingAncestor()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        // Pump01 lives two levels under PlantArea. Browsing PlantArea's children with a
        // Pump glob should still surface LineA (which itself does not match) because it
        // contains a matching descendant.
        BrowseChildrenRequest request = new() { ParentGobjectId = 1, TagNameGlob = "Pump*" };

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);

        GalaxyObject surfaced = Assert.Single(result.Children);
        Assert.Equal(2, surfaced.GobjectId);
        Assert.True(result.ChildHasChildren[0]);
    }

    [Fact]
    public void ProjectChildren_UnknownParent_ThrowsNotFound()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        BrowseChildrenRequest request = new() { ParentGobjectId = 99999 };

        RpcException ex = Assert.Throws<RpcException>(() =>
            GalaxyBrowseProjector.ProjectChildren(entry, request, null, 0, 100));
        Assert.Equal(StatusCode.NotFound, ex.StatusCode);
    }

    [Fact]
    public void ProjectChildren_Paging_SlicesAndPreservesTotal()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        // LineA(2) has two direct children: Pump01, Valve01.
        BrowseChildrenRequest request = new() { ParentGobjectId = 2 };

        GalaxyBrowseChildrenResult page1 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 0, pageSize: 1);
        GalaxyBrowseChildrenResult page2 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 1, pageSize: 1);

        Assert.Equal(2, page1.TotalChildCount);
        Assert.Single(page1.Children);
        Assert.Single(page2.Children);
        Assert.NotEqual(page1.Children[0].GobjectId, page2.Children[0].GobjectId);
        // Same filter+parent => same signature on both pages.
        Assert.Equal(page1.FilterSignature, page2.FilterSignature);
    }

    [Fact]
    public void ResolveParentId_ByTagName_ResolvesToGobjectId()
    {
        GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
        BrowseChildrenRequest request = new() { ParentTagName = "LineA" };

        int id = GalaxyBrowseProjector.ResolveParentId(entry, request);

        Assert.Equal(2, id);
    }
}
