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

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

/// <summary>
/// Covers the B2 Wave-B service prelude added to <see cref="RawTreeService"/> — the tag CRUD/import path
/// (WP4 manual entry + WP5 CSV import) and the driver/device config + merged-probe projections (WP3
/// modals). Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture.
/// <para>
/// As with the WP1 suite, the EF InMemory provider does not enforce <c>RowVersion</c> tokens, so the
/// optimistic-concurrency failure is exercised via the "row vanished under a stale handle" path.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceWaveBTests
{
    private static (RawTreeService Service, string DbName) Seeded()
    {
        var name = RawTreeTestDb.SeedFresh();
        return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
    }

    private static byte[] RowVersionOf(string dbName, Func<OtOpcUaConfigDbContext, byte[]> selector)
    {
        using var db = RawTreeTestDb.CreateNamed(dbName);
        return selector(db);
    }

    private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") =>
        new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig);

    // -------------------------------------------------------------------------------- CreateTag (WP4)

    [Fact]
    public async Task CreateTag_under_group_succeeds_and_returns_new_id()
    {
        var (service, dbName) = Seeded();

        var result = await service.CreateTagAsync(
            RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("torque"));

        result.Ok.ShouldBeTrue();
        result.CreatedId.ShouldNotBeNull();

        using var db = RawTreeTestDb.CreateNamed(dbName);
        var tag = db.Tags.Single(t => t.TagId == result.CreatedId);
        tag.Name.ShouldBe("torque");
        tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId);
        tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId);
    }

    [Fact]
    public async Task CreateTag_collapses_whitespace_pollgroup_to_null()
    {
        var (service, dbName) = Seeded();

        var result = await service.CreateTagAsync(
            RawTreeTestDb.FolderedDeviceId, null,
            new RawTagInput("flow", "Float", TagAccessLevel.Read, false, "   ", "{}"));

        result.Ok.ShouldBeTrue();
        using var db = RawTreeTestDb.CreateNamed(dbName);
        db.Tags.Single(t => t.TagId == result.CreatedId).PollGroupId.ShouldBeNull();
    }

    [Fact]
    public async Task CreateTag_rejects_sibling_name_collision()
    {
        var (service, _) = Seeded();

        // "speed" already exists under RootGroup.
        var dup = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("speed"));

        dup.Ok.ShouldBeFalse();
        dup.Error!.ShouldContain("already exists");
    }

    [Fact]
    public async Task CreateTag_rejects_malformed_tagconfig_json()
    {
        var (service, _) = Seeded();

        var bad = await service.CreateTagAsync(
            RawTreeTestDb.FolderedDeviceId, null, Tag("pressure", tagConfig: "{not json"));

        bad.Ok.ShouldBeFalse();
        bad.Error!.ShouldContain("JSON");
    }

    [Fact]
    public async Task CreateTag_rejects_invalid_name_segment()
    {
        var (service, _) = Seeded();

        var bad = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, null, Tag("a/b"));

        bad.Ok.ShouldBeFalse();
        bad.Error!.ShouldContain("/");
    }

    // -------------------------------------------------------------------------------- UpdateTag (WP4)

    [Fact]
    public async Task UpdateTag_edits_fields_last_write_wins()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);

        var result = await service.UpdateTagAsync(
            RawTreeTestDb.PlainTagId,
            new RawTagInput("running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1", "{\"scale\":2}"),
            rv);

        result.Ok.ShouldBeTrue();
        using var db = RawTreeTestDb.CreateNamed(dbName);
        var tag = db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId);
        tag.Name.ShouldBe("running");
        tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
        tag.WriteIdempotent.ShouldBeTrue();
        tag.PollGroupId.ShouldBe("pg1");
        tag.TagConfig.ShouldContain("scale");
        // Identity location preserved.
        tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId);
        tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId);
    }

    [Fact]
    public async Task UpdateTag_rejects_rename_collision_excluding_self()
    {
        var (service, dbName) = Seeded();
        // Rename "state" (PlainTag) to "speed" — a sibling under the same group ⇒ collision.
        var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);

        var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("speed"), rv);

        result.Ok.ShouldBeFalse();
        result.Error!.ShouldContain("already exists");
    }

    [Fact]
    public async Task UpdateTag_allows_renaming_to_same_name_excluding_self()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);

        // Keeping its own name "state" must not trip the excl-self uniqueness check.
        var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("state", "Boolean"), rv);

        result.Ok.ShouldBeTrue();
    }

    [Fact]
    public async Task UpdateTag_fails_when_row_vanished_concurrently()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);

        using (var db = RawTreeTestDb.CreateNamed(dbName))
        {
            db.Tags.Remove(db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId));
            db.SaveChanges();
        }

        var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("whatever", "Boolean"), rv);

        result.Ok.ShouldBeFalse();
        result.Error.ShouldNotBeNull();
    }

    // ------------------------------------------------------------------------------ ImportTags (WP5)

    [Fact]
    public async Task ImportTags_autocreates_nested_groups_and_reuses_them()
    {
        var (service, dbName) = Seeded();

        var rows = new[]
        {
            new RawTagImportRow("Line/A", Tag("t1")),
            new RawTagImportRow("Line/A", Tag("t2")),   // reuses Line + Line/A
            new RawTagImportRow("Line/B", Tag("t3")),   // reuses Line, new B
        };

        var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);

        result.Errors.ShouldBeEmpty();
        result.Inserted.ShouldBe(3);

        using var db = RawTreeTestDb.CreateNamed(dbName);
        var line = db.TagGroups.Single(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.ParentTagGroupId == null && g.Name == "Line");
        var groupA = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "A");
        var groupB = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "B");
        db.Tags.Count(t => t.TagGroupId == groupA.TagGroupId).ShouldBe(2); // Line/A reused, not duplicated
        db.Tags.Count(t => t.TagGroupId == groupB.TagGroupId).ShouldBe(1);
        // Exactly one "Line" group created despite three references.
        db.TagGroups.Count(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Line").ShouldBe(1);
    }

    [Fact]
    public async Task ImportTags_places_tag_at_device_root_when_path_blank()
    {
        var (service, dbName) = Seeded();

        var result = await service.ImportTagsAsync(
            RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) });

        result.Inserted.ShouldBe(1);
        using var db = RawTreeTestDb.CreateNamed(dbName);
        db.Tags.Single(t => t.Name == "rootTag").TagGroupId.ShouldBeNull();
    }

    [Fact]
    public async Task ImportTags_all_or_nothing_on_a_bad_row_inserts_nothing()
    {
        var (service, dbName) = Seeded();

        var rows = new[]
        {
            new RawTagImportRow("Grp", Tag("good1")),
            new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON ⇒ error
            new RawTagImportRow("Grp", Tag("good2")),
        };

        var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);

        result.Inserted.ShouldBe(0);
        result.Errors.ShouldNotBeEmpty();
        result.Errors[0].ShouldContain("Row 2");

        // Nothing committed — not the two good tags, not the auto-created group.
        using var db = RawTreeTestDb.CreateNamed(dbName);
        db.Tags.Any(t => t.Name == "good1" || t.Name == "good2").ShouldBeFalse();
        db.TagGroups.Any(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Grp").ShouldBeFalse();
    }

    [Fact]
    public async Task ImportTags_detects_intra_batch_duplicate()
    {
        var (service, _) = Seeded();

        var rows = new[]
        {
            new RawTagImportRow("Grp", Tag("dup")),
            new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch
        };

        var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);

        result.Inserted.ShouldBe(0);
        result.Errors.ShouldContain(e => e.Contains("Row 2") && e.Contains("already exists"));
    }

    [Fact]
    public async Task ImportTags_detects_collision_with_existing_tag()
    {
        var (service, _) = Seeded();

        // "temperature" already sits at the device root of RootDevice.
        var result = await service.ImportTagsAsync(
            RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) });

        result.Inserted.ShouldBe(0);
        result.Errors.ShouldContain(e => e.Contains("already exists"));
    }

    // ----------------------------------------------------------------------- Driver config (WP3)

    [Fact]
    public async Task LoadDriverForEdit_projects_fields()
    {
        var (service, _) = Seeded();

        var dto = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);

        dto.ShouldNotBeNull();
        dto!.Name.ShouldBe("modbus-1");
        dto.DriverType.ShouldBe("Modbus");
        dto.DriverConfig.ShouldBe("{}");
    }

    [Fact]
    public async Task LoadDriverForEdit_returns_null_when_missing()
    {
        var (service, _) = Seeded();
        (await service.LoadDriverForEditAsync("DRV-nope")).ShouldBeNull();
    }

    [Fact]
    public async Task UpdateDriver_edits_name_and_config_preserving_type()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);

        var result = await service.UpdateDriverAsync(
            RawTreeTestDb.FolderedDriverId, "modbus-main", "{\"Host\":\"10.0.0.1\"}", "{\"x\":1}", rv);

        result.Ok.ShouldBeTrue();
        using var db = RawTreeTestDb.CreateNamed(dbName);
        var drv = db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId);
        drv.Name.ShouldBe("modbus-main");
        drv.DriverConfig.ShouldContain("Host");
        drv.ResilienceConfig.ShouldBe("{\"x\":1}");
        drv.DriverType.ShouldBe("Modbus"); // immutable
    }

    [Fact]
    public async Task UpdateDriver_rejects_sibling_name_collision()
    {
        var (service, dbName) = Seeded();
        // Two cluster-root vs foldered drivers differ in scope; create a sibling in the same folder to collide.
        await service.CreateDriverAsync(RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "modbus-2", "Modbus", "{}");
        var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);

        var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "modbus-2", "{}", null, rv);

        result.Ok.ShouldBeFalse();
        result.Error!.ShouldContain("already exists");
    }

    [Fact]
    public async Task UpdateDriver_fails_when_row_vanished_concurrently()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);

        using (var db = RawTreeTestDb.CreateNamed(dbName))
        {
            db.Devices.RemoveRange(db.Devices.Where(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId));
            db.Tags.RemoveRange(db.Tags.Where(t => t.DeviceId == RawTreeTestDb.FolderedDeviceId));
            db.TagGroups.RemoveRange(db.TagGroups.Where(g => g.DeviceId == RawTreeTestDb.FolderedDeviceId));
            db.DriverInstances.Remove(db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId));
            db.SaveChanges();
        }

        var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "x", "{}", null, rv);

        result.Ok.ShouldBeFalse();
        result.Error.ShouldNotBeNull();
    }

    // ----------------------------------------------------------------------- Device config (WP3)

    [Fact]
    public async Task LoadDeviceForEdit_projects_fields_with_driver_type_join()
    {
        var (service, _) = Seeded();

        var dto = await service.LoadDeviceForEditAsync(RawTreeTestDb.FolderedDeviceId);

        dto.ShouldNotBeNull();
        dto!.Name.ShouldBe("Device1");
        dto.DriverInstanceId.ShouldBe(RawTreeTestDb.FolderedDriverId);
        dto.Enabled.ShouldBeTrue();
        dto.DriverType.ShouldBe("Modbus"); // joined from the parent driver
    }

    [Fact]
    public async Task LoadDeviceForEdit_returns_null_when_missing()
    {
        var (service, _) = Seeded();
        (await service.LoadDeviceForEditAsync("DEV-nope")).ShouldBeNull();
    }

    [Fact]
    public async Task UpdateDevice_edits_name_config_and_enabled()
    {
        var (service, dbName) = Seeded();
        var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion);

        var result = await service.UpdateDeviceAsync(
            RawTreeTestDb.FolderedDeviceId, "PLC-A", "{\"Host\":\"10.0.0.9\",\"Port\":502}", enabled: false, rv);

        result.Ok.ShouldBeTrue();
        using var db = RawTreeTestDb.CreateNamed(dbName);
        var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId);
        dev.Name.ShouldBe("PLC-A");
        dev.DeviceConfig.ShouldContain("Host");
        dev.Enabled.ShouldBeFalse();
    }

    [Fact]
    public async Task UpdateDevice_rejects_sibling_name_collision()
    {
        var (service, dbName) = Seeded();
        await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device2", "{}");
        var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion);

        var result = await service.UpdateDeviceAsync(RawTreeTestDb.FolderedDeviceId, "Device2", "{}", true, rv);

        result.Ok.ShouldBeFalse();
        result.Error!.ShouldContain("already exists");
    }

    // ------------------------------------------------------------- Merged probe config (Test connect)

    [Fact]
    public async Task LoadMergedProbeConfig_merges_deviceconfig_endpoint_up()
    {
        var (service, dbName) = Seeded();
        // Put an endpoint on the device config (endpoint lives in DeviceConfig in v3).
        using (var db = RawTreeTestDb.CreateNamed(dbName))
        {
            var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId);
            dev.DeviceConfig = "{\"Host\":\"10.1.2.3\",\"Port\":5020}";
            db.SaveChanges();
        }

        var probe = await service.LoadMergedProbeConfigAsync(RawTreeTestDb.FolderedDeviceId);

        probe.ShouldNotBeNull();
        probe!.Value.DriverType.ShouldBe("Modbus");
        // Endpoint key from DeviceConfig appears merged up at the top level of the probe config.
        probe.Value.MergedConfigJson.ShouldContain("Host");
        probe.Value.MergedConfigJson.ShouldContain("10.1.2.3");
    }

    [Fact]
    public async Task LoadMergedProbeConfig_returns_null_for_unknown_device()
    {
        var (service, _) = Seeded();
        (await service.LoadMergedProbeConfigAsync("DEV-nope")).ShouldBeNull();
    }
}
