using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;

/// <summary>
/// Verifies <see cref="ScriptTagCatalog"/> projects ONLY the resolvable keys a virtual-tag script can
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
/// </summary>
/// <remarks>
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. The
/// catalog therefore emits the FullName for equipment tags, the MXAccess dot-ref for SystemPlatform
/// tags, and the leaf Name for virtual tags, and deliberately omits UNS browse paths. These
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptTagCatalogTests
{
    private sealed class TestFactory(DbContextOptions<OtOpcUaConfigDbContext> opts)
        : IDbContextFactory<OtOpcUaConfigDbContext>
    {
        public OtOpcUaConfigDbContext CreateDbContext() => new(opts);

        public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
            Task.FromResult(new OtOpcUaConfigDbContext(opts));
    }

    private static (ScriptTagCatalog Catalog, DbContextOptions<OtOpcUaConfigDbContext> Opts) Fresh()
    {
        var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
            .UseInMemoryDatabase($"scripttagcatalog-{Guid.NewGuid():N}")
            .Options;
        return (new ScriptTagCatalog(new TestFactory(opts)), opts);
    }

    /// <summary>
    /// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
    /// one virtual tag, and one SystemPlatform tag (EquipmentId null, FolderPath set).
    /// </summary>
    private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
    {
        using var db = new OtOpcUaConfigDbContext(opts);

        db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "Assembly" });
        db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "LineA" });
        db.Equipment.Add(new Equipment
        {
            EquipmentId = "EQ-1",
            EquipmentUuid = Guid.NewGuid(),
            UnsLineId = "LINE-1",
            Name = "Machine1",
            MachineCode = "machine_001",
        });

        // Equipment driver tag — the GetTag key is the driver FullName from TagConfig.
        db.Tags.Add(new Tag
        {
            TagId = "TAG-EQ",
            DriverInstanceId = "DRV-1",
            EquipmentId = "EQ-1",
            Name = "Speed",
            DataType = "Float",
            AccessLevel = TagAccessLevel.Read,
            TagConfig = "{\"FullName\":\"Motor.Speed\"}",
        });

        // SystemPlatform tag — EquipmentId null, FolderPath set; Galaxy subscribes by "FolderPath.Name".
        db.Tags.Add(new Tag
        {
            TagId = "TAG-SP",
            DriverInstanceId = "DRV-GALAXY",
            EquipmentId = null,
            Name = "DownloadPath",
            FolderPath = "DelmiaReceiver_001",
            DataType = "String",
            AccessLevel = TagAccessLevel.Read,
            TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
        });

        db.VirtualTags.Add(new VirtualTag
        {
            VirtualTagId = "VTAG-1",
            EquipmentId = "EQ-1",
            Name = "Computed",
            DataType = "Double",
            ScriptId = "SCRIPT-1",
        });

        db.SaveChanges();
    }

    /// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
    /// tag, the MXAccess dot-ref for the SystemPlatform tag, and the virtual tag's leaf Name — and
    /// NONE of the UNS browse paths.</summary>
    [Fact]
    public async Task GetPaths_no_filter_returns_resolvable_keys_only()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var paths = await catalog.GetPathsAsync(null, default);

        // Equipment driver tag: the authoritative GetTag key (driver FullName).
        paths.ShouldContain("Motor.Speed");

        // SystemPlatform tag: MXAccess dot-ref.
        paths.ShouldContain("DelmiaReceiver_001.DownloadPath");

        // Virtual tag: leaf Name only.
        paths.ShouldContain("Computed");

        // The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
        paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
        paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
        paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
    }

    /// <summary>The result is distinct.</summary>
    [Fact]
    public async Task GetPaths_results_are_distinct()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var paths = await catalog.GetPathsAsync(null, default);

        paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
    }

    /// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary>
    [Fact]
    public async Task GetPaths_prefix_filter_narrows()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var paths = await catalog.GetPathsAsync("Motor", default);

        paths.ShouldContain("Motor.Speed");
        paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
        paths.ShouldNotContain("Computed");
        paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>The prefix match is case-insensitive.</summary>
    [Fact]
    public async Task GetPaths_prefix_filter_is_case_insensitive()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var paths = await catalog.GetPathsAsync("motor", default);

        paths.ShouldContain("Motor.Speed");
    }

    /// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
    /// to the raw blob and still returns every other tag.</summary>
    [Fact]
    public async Task GetPaths_malformed_tagconfig_does_not_throw()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.Tags.Add(new Tag
            {
                TagId = "TAG-BAD",
                DriverInstanceId = "DRV-1",
                EquipmentId = "EQ-1",
                Name = "Broken",
                DataType = "Float",
                AccessLevel = TagAccessLevel.Read,
                TagConfig = "{ this is not valid json",
            });
            db.SaveChanges();
        }

        IReadOnlyList<string> paths = [];
        await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));

        // The malformed tag falls through to the raw blob (acceptable), and the other tags still appear.
        paths.ShouldContain("Motor.Speed");
        paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
        paths.ShouldContain("Computed");
    }

    /// <summary>A SystemPlatform tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
    /// (no leading dot).</summary>
    [Fact]
    public async Task GetPaths_systemplatform_tag_without_folder_yields_name_only()
    {
        var (catalog, opts) = Fresh();

        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.Tags.Add(new Tag
            {
                TagId = "TAG-SP-ROOT",
                DriverInstanceId = "DRV-GALAXY",
                EquipmentId = null,
                Name = "RootScalar",
                FolderPath = null,
                DataType = "String",
                AccessLevel = TagAccessLevel.Read,
                TagConfig = "{\"FullName\":\"RootScalar\"}",
            });
            db.SaveChanges();
        }

        var paths = await catalog.GetPathsAsync(null, default);

        paths.ShouldContain("RootScalar");
        paths.ShouldNotContain(".RootScalar");
    }

    /// <summary>An empty database yields an empty list rather than throwing.</summary>
    [Fact]
    public async Task GetPaths_empty_db_returns_empty()
    {
        var (catalog, _) = Fresh();

        var paths = await catalog.GetPathsAsync(null, default);

        paths.ShouldBeEmpty();
    }

    /// <summary>A SystemPlatform tag resolves by its "FolderPath.Name" dot-ref to a Tag-kind info with
    /// the configured DataType + DriverInstanceId.</summary>
    [Fact]
    public async Task GetTagInfo_systemplatform_tag_returns_tag_info()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var info = await catalog.GetTagInfoAsync("DelmiaReceiver_001.DownloadPath", default);

        info.ShouldNotBeNull();
        info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath");
        info.Kind.ShouldBe("Tag");
        info.DataType.ShouldBe("String");
        info.DriverInstanceId.ShouldBe("DRV-GALAXY");
    }

    /// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
    [Fact]
    public async Task GetTagInfo_virtual_tag_returns_virtual_info_with_no_driver()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var info = await catalog.GetTagInfoAsync("Computed", default);

        info.ShouldNotBeNull();
        info!.Path.ShouldBe("Computed");
        info.Kind.ShouldBe("Virtual tag");
        info.DataType.ShouldBe("Double");
        info.DriverInstanceId.ShouldBeNull();
    }

    /// <summary>An unknown path resolves to null.</summary>
    [Fact]
    public async Task GetTagInfo_unknown_path_returns_null()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        (await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull();
    }

    /// <summary>The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT
    /// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying.</summary>
    [Fact]
    public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        (await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull();
        (await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull();
    }

    // -----------------------------------------------------------------------
    // GetEquipmentRelativeLeavesAsync — DB-backed tests
    // -----------------------------------------------------------------------
    // Seeded paths and their leaf derivation:
    //   TAG-EQ  → "Motor.Speed"             → leaf "Speed"
    //   TAG-SP  → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath"
    //   VTAG-1  → "Computed"                → NO dot → excluded
    // -----------------------------------------------------------------------

    /// <summary>A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded.</summary>
    [Fact]
    public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);

        // Leaves derived from the two dot-bearing paths in Seed().
        leaves.ShouldContain("Speed");            // from "Motor.Speed"
        leaves.ShouldContain("DownloadPath");     // from "DelmiaReceiver_001.DownloadPath"

        // Virtual tag "Computed" has no dot — must be excluded.
        leaves.ShouldNotContain("Computed");
    }

    /// <summary>A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath").</summary>
    [Fact]
    public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default);

        leaves.ShouldContain("Speed");
        leaves.ShouldNotContain("DownloadPath");
        leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed".</summary>
    [Fact]
    public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default);

        leaves.ShouldContain("Speed");
    }

    /// <summary>Two paths sharing the same leaf after different object prefixes produce one distinct entry.</summary>
    [Fact]
    public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        // Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ.
        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.Tags.Add(new Tag
            {
                TagId = "TAG-EQ2",
                DriverInstanceId = "DRV-1",
                EquipmentId = "EQ-1",
                Name = "Speed2",
                DataType = "Float",
                AccessLevel = TagAccessLevel.Read,
                TagConfig = "{\"FullName\":\"Conveyor.Speed\"}",
            });
            db.SaveChanges();
        }

        var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);

        // "Speed" must appear exactly once despite being produced by two different paths.
        leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1);
    }
}
