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>
/// <para>
/// Fidelity finding (see the <see cref="ScriptTagCatalog"/> doc comment): the runtime resolves a
/// <c>ctx.GetTag</c> literal through <c>DependencyMuxActor._byRef</c>, keyed Ordinal by the driver's
/// wire reference — and in v3 that wire reference <b>is the RawPath</b>
/// (<c>Folder/…/Driver/Device/[TagGroup/…]/Tag</c>). So the catalog projects RawPaths for raw tags and
/// the leaf <c>Name</c> for virtual tags.
/// </para>
/// <para>
/// These assertions were rewritten for #490. They previously pinned the pre-v3 projection —
/// <c>Tag.TagConfig.FullName</c> — which since v3 is not the mux key at all, so they were encoding a
/// contract the runtime had stopped honouring: completion offered paths that could never resolve, and a
/// correct absolute RawPath resolved to nothing. The seed now builds a real raw topology
/// (RawFolder → DriverInstance → Device → TagGroup → Tag) because a RawPath only exists if that chain
/// does.
/// </para>
/// </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 the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from:
    /// RawFolder "Plant" → DriverInstance "Modbus" → Device "dev1", with one tag directly under the
    /// device and one under a nested TagGroup. A virtual tag is added for the leaf-Name projection.
    /// </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",
        });

        // Raw topology — the ancestry a RawPath is composed from.
        db.RawFolders.Add(new RawFolder { RawFolderId = "RF-1", ClusterId = "MAIN", ParentRawFolderId = null, Name = "Plant" });
        db.DriverInstances.Add(new DriverInstance
        {
            DriverInstanceId = "DRV-1", ClusterId = "MAIN", RawFolderId = "RF-1",
            Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}",
        });
        db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
        db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", ParentTagGroupId = null, Name = "Motors" });

        // Directly under the device -> Plant/Modbus/dev1/Speed
        db.Tags.Add(new Tag
        {
            TagId = "TAG-EQ",
            DeviceId = "DEV-1",
            TagGroupId = null,
            Name = "Speed",
            DataType = "Float",
            AccessLevel = TagAccessLevel.Read,
            TagConfig = "{\"FullName\":\"Motor.Speed\"}",
        });

        // Under a tag group -> Plant/Modbus/dev1/Motors/Torque
        db.Tags.Add(new Tag
        {
            TagId = "TAG-GRP",
            DeviceId = "DEV-1",
            TagGroupId = "TG-1",
            Name = "Torque",
            DataType = "Double",
            AccessLevel = TagAccessLevel.Read,
            TagConfig = "{\"FullName\":\"Motor.Torque\"}",
        });

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

        db.SaveChanges();
    }

    /// <summary>The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the
    /// virtual tag's leaf Name — and NOT the pre-v3 <c>TagConfig.FullName</c>, which is no longer the mux
    /// key and would suggest a path that cannot resolve at runtime (#490).</summary>
    [Fact]
    public async Task GetPaths_no_filter_returns_rawpaths_only()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

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

        paths.ShouldContain("Plant/Modbus/dev1/Speed");          // directly under the device
        paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");  // nested under a tag group
        paths.ShouldContain("Computed");                          // virtual tag: leaf Name

        // The pre-v3 wire address is NOT a resolvable key in v3.
        paths.ShouldNotContain("Motor.Speed");
        paths.ShouldNotContain("Motor.Torque");

        // UNS browse paths are intentionally NOT suggested (that engine is dormant).
        paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
    }

    /// <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). This is the completion
    /// path an author actually hits: typing a partial absolute RawPath inside ctx.GetTag("…").</summary>
    [Fact]
    public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default);

        paths.ShouldContain("Plant/Modbus/dev1/Speed");
        paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");
        paths.ShouldNotContain("Computed");
        paths.ShouldAllBe(p => p.StartsWith("Plant/Modbus/dev1/", StringComparison.OrdinalIgnoreCase));
    }

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

        (await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/Speed");
    }

    /// <summary>A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could
    /// not route it either, so it is omitted rather than guessed — and it must not throw or suppress the
    /// tags that DO resolve.</summary>
    [Fact]
    public async Task GetPaths_tag_with_a_broken_ancestry_chain_is_omitted()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.Tags.Add(new Tag
            {
                TagId = "TAG-ORPHAN",
                DeviceId = "DEV-DOES-NOT-EXIST",
                Name = "Orphan",
                DataType = "Float",
                AccessLevel = TagAccessLevel.Read,
                TagConfig = "{}",
            });
            db.SaveChanges();
        }

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

        paths.ShouldNotContain("Orphan");
        paths.ShouldContain("Plant/Modbus/dev1/Speed");
    }

    /// <summary>A malformed <c>TagConfig</c> is irrelevant to the RawPath (which comes from the topology,
    /// not the blob) — it must neither throw nor drop the tag.</summary>
    [Fact]
    public async Task GetPaths_malformed_tagconfig_does_not_affect_the_rawpath()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.Tags.Add(new Tag
            {
                TagId = "TAG-BAD",
                DeviceId = "DEV-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));

        paths.ShouldContain("Plant/Modbus/dev1/Broken");
        paths.ShouldContain("Plant/Modbus/dev1/Speed");
    }

    /// <summary>A driver at the cluster root (no RawFolder) contributes no folder segment.</summary>
    [Fact]
    public async Task GetPaths_driver_at_cluster_root_has_no_folder_segment()
    {
        var (catalog, opts) = Fresh();

        using (var db = new OtOpcUaConfigDbContext(opts))
        {
            db.DriverInstances.Add(new DriverInstance
            {
                DriverInstanceId = "DRV-ROOT", ClusterId = "MAIN", RawFolderId = null,
                Name = "RootDrv", DriverType = "Modbus", DriverConfig = "{}",
            });
            db.Devices.Add(new Device { DeviceId = "DEV-ROOT", DriverInstanceId = "DRV-ROOT", Name = "d0", DeviceConfig = "{}" });
            db.Tags.Add(new Tag
            {
                TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar",
                DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
            });
            db.SaveChanges();
        }

        (await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar");
    }

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

        (await catalog.GetPathsAsync(null, default)).ShouldBeEmpty();
    }

    /// <summary>Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and —
    /// new in #490 — the owning driver instance, which the topology now makes available.</summary>
    [Fact]
    public async Task GetTagInfo_absolute_rawpath_returns_tag_info_with_driver()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        var info = await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Motors/Torque", default);

        info.ShouldNotBeNull();
        info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque");
        info.Kind.ShouldBe("Tag");
        info.DataType.ShouldBe("Double");
        info.DriverInstanceId.ShouldBe("DRV-1");
    }

    /// <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!.Kind.ShouldBe("Virtual tag");
        info.DataType.ShouldBe("Double");
        info.DriverInstanceId.ShouldBeNull();
    }

    /// <summary>The pre-v3 wire address no longer resolves — the regression #490 is really about. Hovering
    /// one must report "not a known path" rather than validating a string the runtime would drop.</summary>
    [Fact]
    public async Task GetTagInfo_pre_v3_fullname_no_longer_resolves()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        (await catalog.GetTagInfoAsync("Motor.Speed", default)).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("Plant/Modbus/dev1/NoSuchTag", default)).ShouldBeNull();
    }

    /// <summary>The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's
    /// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not
    /// claim it does.</summary>
    [Fact]
    public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);

        (await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull();
        (await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull();
    }

    // -----------------------------------------------------------------------
    // GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
    // -----------------------------------------------------------------------
    // Effective name = UnsTagReference.DisplayNameOverride else the backing raw tag's Name.
    // -----------------------------------------------------------------------

    /// <summary>Seeds equipment EQ-1 with two references: one to TAG-EQ (Name "Speed", no override) and one
    /// to TAG-SP (Name "DownloadPath") under a display override "Path".</summary>
    private static void SeedReferences(DbContextOptions<OtOpcUaConfigDbContext> opts)
    {
        using var db = new OtOpcUaConfigDbContext(opts);
        db.UnsTagReferences.Add(new UnsTagReference
        {
            UnsTagReferenceId = "REF-1", EquipmentId = "EQ-1", TagId = "TAG-EQ", DisplayNameOverride = null,
        });
        db.UnsTagReferences.Add(new UnsTagReference
        {
            UnsTagReferenceId = "REF-2", EquipmentId = "EQ-1", TagId = "TAG-SP", DisplayNameOverride = "Path",
        });
        db.SaveChanges();
    }

    /// <summary>Returns the equipment's reference effective names — backing tag Name when no override, the
    /// override otherwise.</summary>
    [Fact]
    public async Task GetEquipmentReferenceNames_returns_effective_names()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);
        SeedReferences(opts);

        var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", null, default);

        names.ShouldContain("Speed");       // TAG-EQ Name, no override
        names.ShouldContain("Path");        // TAG-SP under the "Path" override
        names.ShouldNotContain("DownloadPath"); // the raw Name is superseded by the override
    }

    /// <summary>A prefix filter narrows (case-insensitively) to reference names that start with the prefix.</summary>
    [Fact]
    public async Task GetEquipmentReferenceNames_prefix_filter_is_case_insensitive()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);
        SeedReferences(opts);

        var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", "sp", default);

        names.ShouldContain("Speed");
        names.ShouldNotContain("Path");
    }

    /// <summary>An unknown equipment (or one with no references) yields an empty set; a blank id yields empty.</summary>
    [Fact]
    public async Task GetEquipmentReferenceNames_empty_for_unknown_or_blank_equipment()
    {
        var (catalog, opts) = Fresh();
        Seed(opts);
        SeedReferences(opts);

        (await catalog.GetEquipmentReferenceNamesAsync("EQ-UNKNOWN", null, default)).ShouldBeEmpty();
        (await catalog.GetEquipmentReferenceNamesAsync("", null, default)).ShouldBeEmpty();
    }
}
