using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;

/// <summary>
///     Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
///     <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
///     don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
///     follow-up PR once we have a mock S7 server.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DiscoveryAndSubscribeTests
{
    // RecordingAddressSpaceBuilder lives in S7TestBuilders.cs — shared across the S7 test suite.

    /// <summary>Verifies that DiscoverAsync projects every configured tag into the address space.</summary>
    [Fact]
    public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
    {
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Probe = new S7ProbeOptions { Enabled = false },
            RawTags = S7RawTags.Entries(
                new S7TagDefinition("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
                new S7TagDefinition("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
                new S7TagDefinition("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true)),
        };
        // v3: DiscoverAsync projects the RawPath → definition table, which is built at Initialize —
        // so the driver must be initialized (with a connecting fake PLC) before Discover.
        using var drv = new S7Driver(opts, "s7-disco", new ConnectingFakeS7PlcFactory());
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var builder = new RecordingAddressSpaceBuilder();
        await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);

        builder.Folders.ShouldContain("S7");
        builder.Variables.Count.ShouldBe(3);
        builder.Variables[0].Name.ShouldBe("TempSetpoint");
        builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
        builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
        builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
    }

    /// <summary>Verifies that DiscoverAsync propagates the WriteIdempotent flag from tag configuration to attribute info.</summary>
    [Fact]
    public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info()
    {
        var opts = new S7DriverOptions
        {
            Host = "192.0.2.1",
            Probe = new S7ProbeOptions { Enabled = false },
            RawTags = S7RawTags.Entries(
                new S7TagDefinition("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true),
                new S7TagDefinition("StartBit", "M0.0", S7DataType.Bool)),
        };
        // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
        using var drv = new S7Driver(opts, "s7-idem", new ConnectingFakeS7PlcFactory());
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var builder = new RecordingAddressSpaceBuilder();
        await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);

        builder.Variables.Single(v => v.Name == "SetPoint").Attr.WriteIdempotent.ShouldBeTrue();
        builder.Variables.Single(v => v.Name == "StartBit").Attr.WriteIdempotent.ShouldBeFalse("default is opt-in per decision #44");
    }

    /// <summary>Verifies that GetHostStatuses returns one row with the host:port identity in pre-init state.</summary>
    [Fact]
    public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
    {
        var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
        using var drv = new S7Driver(opts, "s7-host");

        var rows = drv.GetHostStatuses();
        rows.Count.ShouldBe(1);
        rows[0].HostName.ShouldBe("plc1.internal:102");
        rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
    }

    /// <summary>Verifies that SubscribeAsync returns unique handles and UnsubscribeAsync correctly accepts them.</summary>
    [Fact]
    public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
    {
        var opts = new S7DriverOptions { Host = "192.0.2.1" };
        using var drv = new S7Driver(opts, "s7-sub");

        // SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
        // even though the driver isn't initialized. The poll task catches the resulting
        // InvalidOperationException and the loop quietly continues — same pattern as the
        // Modbus driver's poll loop tolerating transient transport failures.
        var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
        var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);

        // Post poll-fork retirement (CONV-1) the subscription handle is the shared engine's
        // PollSubscriptionHandle (`poll-sub-<id>`), not the retired S7SubscriptionHandle (`s7-sub-`).
        h1.DiagnosticId.ShouldStartWith("poll-sub-");
        h2.DiagnosticId.ShouldStartWith("poll-sub-");
        h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);

        await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
        await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
        // UnsubscribeAsync with an unknown handle must be a no-op, not throw.
        await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
    }

    /// <summary>Verifies that Subscribe floors the publishing interval at 100ms.</summary>
    [Fact]
    public async Task Subscribe_publishing_interval_is_floored_at_100ms()
    {
        var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
        using var drv = new S7Driver(opts, "s7-floor");

        // 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
        // just queue wire-side. Test that the subscription is accepted (the floor is applied
        // internally; the floor value isn't exposed, so we're really just asserting that the
        // driver doesn't reject small intervals).
        var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
        h.ShouldNotBeNull();
        await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
    }
}
