using Mbproxy.Bcd;
using Mbproxy.Options;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Bcd;

/// <summary>
/// Unit tests for <see cref="BcdTagMapBuilder.Build"/> and the resulting <see cref="BcdTagMap"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BcdTagMapBuilderTests
{
    // ── Helpers ──────────────────────────────────────────────────────────────

    private static BcdTagListOptions Global(params (ushort addr, byte width)[] tags)
        => new() { Global = tags.Select(t => new BcdTagOptions { Address = t.addr, Width = t.width }).ToList() };

    private static PlcBcdOverrides Override(
        (ushort addr, byte width)[]? add = null,
        ushort[]? remove = null)
        => new()
        {
            Add = add?.Select(t => new BcdTagOptions { Address = t.addr, Width = t.width }).ToList()
                  ?? [],
            Remove = remove ?? [],
        };

    // ── Build tests ──────────────────────────────────────────────────────────

    [Fact]
    public void Build_EmptyGlobal_EmptyOverride_ReturnsEmptyMap()
    {
        var result = BcdTagMapBuilder.Build(new BcdTagListOptions(), perPlc: null);

        result.Errors.ShouldBeEmpty();
        result.Warnings.ShouldBeEmpty();
        result.Map.Count.ShouldBe(0);
        result.Map.ShouldBeSameAs(BcdTagMap.Empty);
    }

    [Fact]
    public void Build_GlobalOnly_PopulatesMap()
    {
        var global = Global((1072, 16), (1080, 32));

        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Errors.ShouldBeEmpty();
        result.Map.Count.ShouldBe(2);
        result.Map.TryGet(1072, out var t16).ShouldBeTrue();
        t16.Width.ShouldBe((byte)16);
        result.Map.TryGet(1080, out var t32).ShouldBeTrue();
        t32.Width.ShouldBe((byte)32);
    }

    [Fact]
    public void Build_PerPlcAdd_AppendsToGlobal()
    {
        var global = Global((1072, 16));
        var perPlc = Override(add: [(1200, 32)]);

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldBeEmpty();
        result.Map.Count.ShouldBe(2);
        result.Map.TryGet(1200, out var added).ShouldBeTrue();
        added.Width.ShouldBe((byte)32);
    }

    [Fact]
    public void Build_PerPlcRemove_DropsFromGlobal()
    {
        var global = Global((1072, 16), (1080, 32));
        var perPlc = Override(remove: [1080]);

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldBeEmpty();
        result.Warnings.ShouldBeEmpty();
        result.Map.Count.ShouldBe(1);
        result.Map.TryGet(1080, out _).ShouldBeFalse();
        result.Map.TryGet(1072, out _).ShouldBeTrue();
    }

    [Fact]
    public void Build_AddOverrideSameAddressAsGlobal_AddWidthWins()
    {
        // Global says 16-bit at 1072; per-PLC Add says 32-bit at 1072. Add wins.
        var global = Global((1072, 16));
        var perPlc = Override(add: [(1072, 32)]);

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldBeEmpty();
        result.Map.Count.ShouldBe(1);
        result.Map.TryGet(1072, out var tag).ShouldBeTrue();
        tag.Width.ShouldBe((byte)32);
    }

    /// <summary>
    /// Duplicates within Global itself are detected pre-collapse and produce a
    /// DuplicateAddress error. (A naive input dictionary would silently collapse
    /// to last-write-wins, leaving the validator dead.)
    /// </summary>
    [Fact]
    public void Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError()
    {
        var global = new BcdTagListOptions
        {
            Global =
            [
                new BcdTagOptions { Address = 1072, Width = 16 },
                new BcdTagOptions { Address = 1072, Width = 32 },  // duplicate within Global
            ]
        };

        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Errors.ShouldContain(e => e.Kind == BcdValidationError.DuplicateAddress
                                       && e.Address == 1072);
    }

    /// <summary>
    /// Duplicates within the per-PLC Add list itself are detected pre-collapse.
    /// (Cross-list collisions Global vs Add remain the legitimate width-override
    /// pattern and are NOT errors — see the next test.)
    /// </summary>
    [Fact]
    public void Build_DuplicateAddress_Within_AddList_ReturnsDuplicateAddressError()
    {
        var global = Global((1072, 16));
        var perPlc = new PlcBcdOverrides
        {
            Add =
            [
                new BcdTagOptions { Address = 1080, Width = 16 },
                new BcdTagOptions { Address = 1080, Width = 32 },  // duplicate within Add
            ],
            Remove = [],
        };

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldContain(e => e.Kind == BcdValidationError.DuplicateAddress
                                       && e.Address == 1080);
    }

    /// <summary>
    /// Same-address entries appearing in BOTH Global AND Add are the documented
    /// width-override pattern (docs/Features/BcdRewriting.md "Hybrid tag resolution"). They must NOT
    /// be flagged as duplicates; Add wins.
    /// </summary>
    [Fact]
    public void Build_AddOverridesGlobalAtSameAddress_NoDuplicateError_AddWins()
    {
        var global = Global((1072, 16));
        var perPlc = new PlcBcdOverrides
        {
            Add    = [ new BcdTagOptions { Address = 1072, Width = 32 } ],
            Remove = [],
        };

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldBeEmpty();
        result.Map.TryGet(1072, out var tag).ShouldBeTrue();
        tag.Width.ShouldBe((byte)32);
    }

    [Fact]
    public void Build_32BitHighRegOverlaps16BitGlobal_ReturnsOverlappingHighRegisterError()
    {
        // Tag at 1080 is 32-bit → occupies 1080 and 1081.
        // Separate 16-bit tag at 1081 → high-register collision.
        var global = Global((1080, 32), (1081, 16));

        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Errors.ShouldContain(e => e.Kind == BcdValidationError.OverlappingHighRegister);
    }

    [Fact]
    public void Build_Remove_OfNonExistentAddress_ReturnsWarning_NotError()
    {
        var global = Global((1072, 16));
        var perPlc = Override(remove: [9999]);   // 9999 is not in global

        var result = BcdTagMapBuilder.Build(global, perPlc);

        result.Errors.ShouldBeEmpty();
        result.Warnings.Count.ShouldBe(1);
        result.Warnings[0].Address.ShouldBe((ushort)9999);
    }

    [Fact]
    public void Build_InvalidWidth_ReturnsInvalidWidthError()
    {
        // Width 8 is not valid BCD.
        var global = new BcdTagListOptions
        {
            Global = [new BcdTagOptions { Address = 1072, Width = 8 }]
        };

        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Errors.ShouldContain(e => e.Kind == BcdValidationError.InvalidWidth);
    }

    // ── TryGetForRange ───────────────────────────────────────────────────────

    [Fact]
    public void Map_TryGetForRange_ReturnsAllHits_InOrder()
    {
        // Layout:
        //   1070 → 16-bit           (just outside range from the left)
        //   1072 → 16-bit           (inside range)
        //   1074 → 32-bit           (1074 and 1075, both inside range)
        //   1076 → 32-bit           (1076 and 1077 — 1076 inside, 1077 outside)
        //   1078 → 16-bit           (just outside range on the right)
        //
        // Read range: start=1072, qty=5 → covers [1072, 1077).

        var global = Global(
            (1070, 16),   // before range
            (1072, 16),   // in range, offset 0
            (1074, 32),   // in range, offsets 2 and 3
            (1076, 32),   // partial overlap: 1076 in range (offset 4), 1077 outside
            (1078, 16));  // after range

        var result = BcdTagMapBuilder.Build(global, perPlc: null);
        result.Errors.ShouldBeEmpty();

        bool found = result.Map.TryGetForRange(1072, 5, out var hits);

        found.ShouldBeTrue();

        // Expected hits (sorted by offset):
        //   offset 0 → tag at 1072 (16-bit)
        //   offset 2 → tag at 1074 (32-bit)
        //   offset 4 → tag at 1076 (32-bit, partial overlap)
        hits.Count.ShouldBe(3);
        hits[0].OffsetWords.ShouldBe(0);
        hits[0].Tag.Address.ShouldBe((ushort)1072);
        hits[1].OffsetWords.ShouldBe(2);
        hits[1].Tag.Address.ShouldBe((ushort)1074);
        hits[2].OffsetWords.ShouldBe(4);
        hits[2].Tag.Address.ShouldBe((ushort)1076);
    }

    [Fact]
    public void Map_TryGetForRange_NoOverlap_ReturnsFalse_NoAllocation()
    {
        // A read of a completely different address region → no hits.
        var global = Global((1072, 16), (1080, 32));
        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        bool found = result.Map.TryGetForRange(2000, 10, out var hits);

        found.ShouldBeFalse();
        hits.Count.ShouldBe(0);
        // The returned list should be the static empty sentinel (no allocation).
        hits.ShouldBeSameAs(hits);  // identity check placeholder — see note below
    }

    [Fact]
    public void Map_TryGetForRange_32BitTagPartialOverlapLowOnly_IsIncluded()
    {
        // 32-bit tag at 1080 (occupies 1080, 1081).
        // Read start=1080, qty=1 → covers only register 1080 (the low word).
        // Tag intersects → should be returned with offset 0.
        var global = Global((1080, 32));
        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        bool found = result.Map.TryGetForRange(1080, 1, out var hits);

        found.ShouldBeTrue();
        hits.Count.ShouldBe(1);
        hits[0].OffsetWords.ShouldBe(0);
        hits[0].Tag.Address.ShouldBe((ushort)1080);
    }

    [Fact]
    public void Map_TryGetForRange_32BitTagPartialOverlapHighOnly_IsIncluded()
    {
        // 32-bit tag at 1080 (occupies 1080, 1081).
        // Read start=1081, qty=1 → covers only register 1081 (the high word).
        // Tag intersects → offset = 1080 - 1081 = -1.
        var global = Global((1080, 32));
        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        bool found = result.Map.TryGetForRange(1081, 1, out var hits);

        found.ShouldBeTrue();
        hits.Count.ShouldBe(1);
        hits[0].OffsetWords.ShouldBe(-1);   // low word is 1 before the start of the range
        hits[0].Tag.Address.ShouldBe((ushort)1080);
    }

    [Fact]
    public void Map_TryGet_MissAddress_ReturnsFalse()
    {
        var global = Global((1072, 16));
        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Map.TryGet(9999, out _).ShouldBeFalse();
    }

    [Fact]
    public void Map_TryGetForRange_EmptyMap_ReturnsFalse()
    {
        bool found = BcdTagMap.Empty.TryGetForRange(1072, 10, out var hits);

        found.ShouldBeFalse();
        hits.Count.ShouldBe(0);
    }

    [Fact]
    public void Map_Count_And_All_ReflectBuiltEntries()
    {
        var global = Global((1072, 16), (1080, 32), (1200, 16));
        var result = BcdTagMapBuilder.Build(global, perPlc: null);

        result.Map.Count.ShouldBe(3);
        result.Map.All.Count().ShouldBe(3);
    }
}
