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

namespace ZB.MOM.WW.GalaxyRepository.Tests;

/// <summary>
///     Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
///     resolution (gobject id / tag name / contained path), paging across siblings,
///     filter parity with <see cref="GalaxyHierarchyProjector"/>, the
///     <c>child_has_children</c> hint, browse-subtree constraints, and the
///     attribute-skeleton mode.
/// </summary>
public sealed class GalaxyBrowseProjectorTests
{
    /// <summary>Verifies that an empty parent oneof returns the root area.</summary>
    [Fact]
    public void Project_NoParent_ReturnsRootArea()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest(),
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        Assert.Single(result.Children);
        Assert.Equal("Plant", result.Children[0].TagName);
        Assert.True(result.ChildHasChildren[0]);
    }

    /// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
    [Fact]
    public void Project_ByParentGobjectId_ReturnsDirectChildren()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 1 },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        string[] names = result.Children.Select(child => child.TagName).ToArray();
        Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
        Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
        Assert.Equal(4, result.TotalChildCount);
    }

    /// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
    [Fact]
    public void Project_ByParentTagName_ResolvesParent()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentTagName = "Plant" },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        string[] names = result.Children.Select(child => child.TagName).ToArray();
        Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
    }

    /// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
    [Fact]
    public void Project_ByParentContainedPath_ResolvesParent()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentContainedPath = "Plant" },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        string[] names = result.Children.Select(child => child.TagName).ToArray();
        Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
    }

    /// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
    [Fact]
    public void Project_UnknownParent_ThrowsNotFound()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 999 },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10));

        Assert.Equal(StatusCode.NotFound, exception.StatusCode);
    }

    /// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
    [Fact]
    public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 1 },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 2);
        GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 1 },
            browseSubtreeGlobs: null,
            offset: 2,
            pageSize: 2);

        List<string> collected = first.Children
            .Concat(second.Children)
            .Select(child => child.TagName)
            .ToList();
        Assert.Equal(4, collected.Count);
        Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
        Assert.Equal(
            new HashSet<string>(StringComparer.Ordinal)
            {
                "Plant.Line_A",
                "Plant.Mixer_001",
                "Plant.Mixer_002",
                "Plant.Pump_001",
            },
            new HashSet<string>(collected, StringComparer.Ordinal));
    }

    /// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
    [Fact]
    public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest
            {
                ParentGobjectId = 1,
                TagNameGlob = "*Mixer*",
            },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        string[] names = result.Children.Select(child => child.TagName).ToArray();
        Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
        Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
    }

    /// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
    [Fact]
    public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest
            {
                ParentGobjectId = 1,
                HistorizedOnly = true,
            },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        // Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
        // so the subtree match keeps Line_A in the result with has-children = true.
        // Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
        // no historized descendants -> filtered out entirely.
        Assert.Single(result.Children);
        Assert.Equal("Plant.Line_A", result.Children[0].TagName);
        Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
    }

    /// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
    [Fact]
    public void Project_IncludeAttributesFalse_ReturnsSkeletons()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest
            {
                ParentGobjectId = 1,
                IncludeAttributes = false,
            },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
        Assert.Empty(mixer.Attributes);
    }

    /// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
    [Fact]
    public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
    {
        GalaxyHierarchyCacheEntry entry = CreateEntry();

        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 1 },
            browseSubtreeGlobs: new[] { "Plant/Line_*" },
            offset: 0,
            pageSize: 10);

        Assert.Single(result.Children);
        Assert.Equal("Plant.Line_A", result.Children[0].TagName);
    }

    /// <summary>
    ///     Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
    ///     contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
    ///     <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
    ///     5-second xUnit timeout asserts termination.
    /// </summary>
    [Fact(Timeout = 5000)]
    public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
    {
        await Task.Yield();
        // Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
        // points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
        //   [12] = [A], [10] = [B], [11] = [C].
        // None of them are historized, so HistorizedOnly=true forces the projector to
        // call HasMatchingDescendant on every direct child, exercising the cycle walk.
        GalaxyObject a = new()
        {
            GobjectId = 10,
            ParentGobjectId = 12,
            ContainedName = "A",
            BrowseName = "A",
            TagName = "A",
        };
        GalaxyObject b = new()
        {
            GobjectId = 11,
            ParentGobjectId = 10,
            ContainedName = "B",
            BrowseName = "B",
            TagName = "B",
        };
        GalaxyObject c = new()
        {
            GobjectId = 12,
            ParentGobjectId = 11,
            ContainedName = "C",
            BrowseName = "C",
            TagName = "C",
        };

        IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
        GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 1,
            LastSuccessAt = DateTimeOffset.UtcNow,
            Objects = objects,
            Index = GalaxyHierarchyIndex.Build(objects),
            ObjectCount = objects.Count,
        };

        // Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
        // projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
        // without the visited-id guard. With the guard, the walk terminates and returns
        // an empty page (no historized descendants exist anywhere in the cycle).
        GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
            entry,
            new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
            browseSubtreeGlobs: null,
            offset: 0,
            pageSize: 10);

        Assert.Empty(result.Children);
        Assert.Equal(0, result.TotalChildCount);
    }

    private static GalaxyHierarchyCacheEntry CreateEntry()
    {
        IReadOnlyList<GalaxyObject> objects = CreateObjects();
        return GalaxyHierarchyCacheEntry.Empty with
        {
            Status = GalaxyCacheStatus.Healthy,
            Sequence = 1,
            LastSuccessAt = DateTimeOffset.UtcNow,
            Objects = objects,
            Index = GalaxyHierarchyIndex.Build(objects),
            ObjectCount = objects.Count,
        };
    }

    private static IReadOnlyList<GalaxyObject> CreateObjects()
    {
        GalaxyObject plant = new()
        {
            GobjectId = 1,
            ParentGobjectId = 0,
            IsArea = true,
            ContainedName = "Plant",
            BrowseName = "Plant",
            TagName = "Plant",
        };
        GalaxyObject mixer001 = new()
        {
            GobjectId = 2,
            ParentGobjectId = 1,
            ContainedName = "Mixer_001",
            BrowseName = "Mixer_001",
            TagName = "Plant.Mixer_001",
        };
        mixer001.Attributes.Add(new GalaxyAttribute
        {
            AttributeName = "Speed",
            FullTagReference = "Plant.Mixer_001.Speed",
        });
        GalaxyObject mixer002 = new()
        {
            GobjectId = 3,
            ParentGobjectId = 1,
            ContainedName = "Mixer_002",
            BrowseName = "Mixer_002",
            TagName = "Plant.Mixer_002",
        };
        GalaxyObject lineA = new()
        {
            GobjectId = 4,
            ParentGobjectId = 1,
            IsArea = true,
            ContainedName = "Line_A",
            BrowseName = "Line_A",
            TagName = "Plant.Line_A",
        };
        GalaxyObject sensorA1 = new()
        {
            GobjectId = 5,
            ParentGobjectId = 4,
            ContainedName = "Sensor_A1",
            BrowseName = "Sensor_A1",
            TagName = "Plant.Line_A.Sensor_A1",
        };
        sensorA1.Attributes.Add(new GalaxyAttribute
        {
            AttributeName = "Value",
            FullTagReference = "Plant.Line_A.Sensor_A1.Value",
            IsHistorized = true,
        });
        GalaxyObject pump001 = new()
        {
            GobjectId = 6,
            ParentGobjectId = 1,
            ContainedName = "Pump_001",
            BrowseName = "Pump_001",
            TagName = "Plant.Pump_001",
        };

        return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
    }
}
