using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     The v3 wire contract: the runtime hands this driver its authored tags as a <c>RawTags</c>
///     array of <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob) and then
///     reads, subscribes, and routes published values <b>by RawPath</b> — never by the driver's own
///     internal address (here, the MTConnect DataItem <c>id</c>).
/// </summary>
/// <remarks>
///     <para>
///         <b>Every config in this file is built by the real
///         <see cref="DriverDeviceConfigMerger"/></b> — the same merge
///         <c>DeploymentArtifact.TryReadSpec</c> runs to produce a
///         <c>DriverInstanceSpec.DriverConfig</c>. A hand-written JSON literal would only prove the
///         driver binds the shape the test author imagined; this proves it binds the shape the
///         deploy artifact actually produces, including the Pascal-case property names
///         <c>RawTagEntry</c> serialises under and the <c>TagConfig</c> blob arriving as an escaped
///         string rather than a nested object.
///     </para>
///     <para>
///         <b>The load-bearing assertion is the reference on the event, not the value.</b>
///         <c>DriverHostActor</c> routes a published value by <c>(DriverInstanceId, RawPath)</c>
///         against a NodeId table it built from the same RawPaths; a driver that published a
///         perfectly correct value under its own dataItemId would miss that table on every tick and
///         the value would be dropped silently — with no error anywhere and every unit test green.
///     </para>
/// </remarks>
public sealed class MTConnectRawTagBindingTests
{
    private const string AgentUri = "http://fixture-agent:5000";

    /// <summary>The DataItem ids the canned <c>Fixtures/</c> documents report.</summary>
    private const string PositionDataItemId = "dev1_pos";
    private const string ExecutionDataItemId = "dev1_execution";
    private const string PartCountDataItemId = "dev1_partcount";

    /// <summary>The RawPaths a deployed <c>/raw</c> tree would give those data items.</summary>
    private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
    private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution";
    private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount";

    /// <summary>The <c>instanceId</c> every canned fixture shares.</summary>
    private const long FixtureInstanceId = 1655000000L;

    /// <summary>The cursor <c>Fixtures/current.xml</c> primes the pump with.</summary>
    private const long PrimedNextSequence = 108L;

    private const uint Good = 0x00000000u;
    private const uint BadNoCommunication = 0x80310000u;
    private const uint BadNodeIdUnknown = 0x80340000u;
    private const uint BadTypeMismatch = 0x80740000u;

    private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);

    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    /// <summary>
    ///     The constructor options a spawned driver starts from: the endpoint only. Everything the
    ///     data plane binds arrives in the merged config document, exactly as it does in production.
    /// </summary>
    private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri };

    /// <summary>
    ///     Builds the merged <c>DriverConfig</c> for one MTConnect driver instance the way the deploy
    ///     artifact does: driver-level config + the device's <c>DeviceConfig</c> + the authored raw
    ///     tags injected as the <c>RawTags</c> array.
    /// </summary>
    private static string MergedConfig(params RawTagEntry[] rawTags) =>
        DriverDeviceConfigMerger.Merge(
            $$"""{"AgentUri":"{{AgentUri}}"}""",
            [new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")],
            rawTags);

    /// <summary>One authored raw tag: a RawPath bound to a DataItem id, typed for coercion.</summary>
    private static RawTagEntry Entry(string rawPath, string dataItemId, string? driverDataType = null) =>
        new(
            rawPath,
            driverDataType is null
                ? $$"""{"fullName":"{{dataItemId}}"}"""
                : $$"""{"fullName":"{{dataItemId}}","driverDataType":"{{driverDataType}}"}""",
            WriteIdempotent: false,
            DeviceName: "dev1");

    private static MTConnectStreamsResult Chunk(
        long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
        new(
            FixtureInstanceId,
            nextSequence,
            firstSequence,
            [.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);

    private static IReadOnlyList<DataChangeEventArgs> For(
        ConcurrentQueue<DataChangeEventArgs> seen, string reference) =>
        [.. seen.Where(e => e.FullReference == reference)];

    private static ConcurrentQueue<DataChangeEventArgs> Record(MTConnectDriver driver)
    {
        var seen = new ConcurrentQueue<DataChangeEventArgs>();
        driver.OnDataChange += (_, e) => seen.Enqueue(e);

        return seen;
    }

    private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> DeployedAsync(
        params RawTagEntry[] rawTags)
    {
        var agent = CannedAgentClient.FromFixtures();
        var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
        await driver.InitializeAsync(MergedConfig(rawTags), Ct);

        return (driver, agent);
    }

    // ---- the production data plane: subscribe by RawPath, publish by RawPath ----

    /// <summary>
    ///     The whole blocker in one case: a deployed driver is subscribed by <b>RawPath</b> (that is
    ///     what <c>DriverInstanceActor.HandleSubscribeAsync</c> passes, and what
    ///     <c>DriverHostActor</c>'s NodeId table is keyed by) and must answer under that same
    ///     RawPath — both for the initial value and for every streamed chunk.
    /// </summary>
    [Fact]
    public async Task Subscribing_by_raw_path_publishes_under_the_raw_path()
    {
        var (driver, agent) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
        var seen = Record(driver);

        await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);

        // Initial data, out of the priming /current — under the RawPath, not the dataItemId.
        var initial = For(seen, PositionRawPath).ShouldHaveSingleItem();
        initial.Snapshot.StatusCode.ShouldBe(Good);
        initial.Snapshot.Value.ShouldBe(123.4567d);

        await agent.PumpAsync(
            Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));

        var streamed = For(seen, PositionRawPath);
        streamed.Count.ShouldBe(2);
        streamed[1].Snapshot.StatusCode.ShouldBe(Good);
        streamed[1].Snapshot.Value.ShouldBe(201.5d);

        // …and NOTHING is ever published under the driver-internal dataItemId: a value routed by that
        // reference misses DriverHostActor's NodeId table and is dropped without a trace.
        For(seen, PositionDataItemId).ShouldBeEmpty();

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     The coercion type must survive the new binding path. The <c>TagConfig</c> blob's
    ///     <c>driverDataType</c> is what tells the observation index to publish <c>201.5</c> as a
    ///     <see cref="double"/> rather than the Agent's raw text — losing it would turn every value
    ///     into a Good-coded string, which the OPC UA node (typed Double) cannot carry.
    /// </summary>
    [Fact]
    public async Task Raw_tag_config_carries_the_coercion_type_into_the_index()
    {
        var (driver, _) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
            Entry(ExecutionRawPath, ExecutionDataItemId, nameof(DriverDataType.String)));
        var seen = Record(driver);

        await driver.SubscribeAsync([PositionRawPath, ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);

        For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBeOfType<double>();
        For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType<string>();

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     <see cref="IReadable.ReadAsync"/> keys by RawPath too. The server never calls it (the data
    ///     plane is subscribe-only), but the driver CLI does, and a read that answered
    ///     <c>BadNodeIdUnknown</c> for a tag the subscription serves happily would be a lie about the
    ///     deployment.
    /// </summary>
    [Fact]
    public async Task Read_resolves_raw_paths()
    {
        var (driver, _) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
            Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));

        var values = await driver.ReadAsync([PositionRawPath, PartCountRawPath, "Plant/MTConnect/dev1/Nope"], Ct);

        values.Count.ShouldBe(3);
        values[0].StatusCode.ShouldBe(Good);
        values[0].Value.ShouldBe(123.4567d);

        // Authored + bound, but the Agent reports it UNAVAILABLE in the fixture.
        values[1].StatusCode.ShouldBe(BadNoCommunication);

        // Not in RawTags at all: a miss is a miss, surfaced as Bad — never a throw.
        values[2].StatusCode.ShouldBe(BadNodeIdUnknown);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     The AdminUI's typed editor (<c>MTConnectTagConfigModel</c>) writes the coercion type under
    ///     <c>dataType</c>, not <c>driverDataType</c>. Reading only the driver's own spelling would
    ///     make every editor-authored tag fall back to String — the editor and the runtime disagreeing
    ///     about the type of every value, with nothing anywhere reporting it.
    /// </summary>
    [Fact]
    public async Task The_admin_ui_datatype_spelling_reaches_the_coercion()
    {
        var (driver, _) = await DeployedAsync(
            new RawTagEntry(
                PositionRawPath,
                """{"fullName":"dev1_pos","dataType":"Float64","mtCategory":"SAMPLE","mtType":"POSITION"}""",
                WriteIdempotent: false,
                DeviceName: "dev1"));
        var seen = Record(driver);

        await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);

        For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     A browse-committed tag's blob carries ONLY the address (<c>RawBrowseCommitMapper</c> has no
    ///     MTConnect branch, so it writes the generic <c>{"address": id}</c> shape) — no type at all.
    ///     The driver falls back to the Agent's own <c>/probe</c> declaration, so a POSITION SAMPLE
    ///     publishes as a <see cref="double"/> rather than as the Agent's raw text under a
    ///     numerically-typed OPC UA node.
    /// </summary>
    [Fact]
    public async Task A_typeless_blob_takes_the_type_the_agent_declares()
    {
        var (driver, _) = await DeployedAsync(
            new RawTagEntry(PositionRawPath, """{"address":"dev1_pos"}""", false, "dev1"));
        var seen = Record(driver);

        await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);

        var initial = For(seen, PositionRawPath)[0];
        initial.Snapshot.StatusCode.ShouldBe(Good);
        initial.Snapshot.Value.ShouldBeOfType<double>().ShouldBe(123.4567d);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     A <see cref="MTConnectDriverOptions.Tags"/> entry supplies the coercion type for a raw tag
    ///     whose blob declares none — the middle rung of the documented precedence, and what keeps a
    ///     driver authored through both surfaces coherent.
    /// </summary>
    [Fact]
    public async Task Tags_supply_the_type_for_a_raw_tag_whose_blob_declares_none()
    {
        var agent = CannedAgentClient.FromFixtures();

        // dev1_execution is an EVENT the probe model would infer as String; the Tags entry overrides
        // that with Reference (a type nothing could infer), so the assertion can only pass if the
        // Tags entry — not the inference — supplied it.
        var driver = new MTConnectDriver(
            new MTConnectDriverOptions
            {
                AgentUri = AgentUri,
                Tags = [new MTConnectTagDefinition(ExecutionDataItemId, DriverDataType.Int32)],
            },
            "mt1",
            _ => agent);

        // A config document with a body REPLACES the constructor options, so the Tags entry has to
        // travel in it too — this is the shape a driver authored through both surfaces deploys as.
        await driver.InitializeAsync(
            $$"""
              {"AgentUri":"{{AgentUri}}",
               "Tags":[{"FullName":"{{ExecutionDataItemId}}","DriverDataType":"Int32"}],
               "RawTags":[{"RawPath":"{{ExecutionRawPath}}","TagConfig":"{\"fullName\":\"{{ExecutionDataItemId}}\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
              """,
            Ct);

        var seen = Record(driver);
        await driver.SubscribeAsync([ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);

        // The fixture reports dev1_execution as "ACTIVE" — not an Int32, so the Int32 the Tags entry
        // declared is what makes this a type mismatch instead of a Good string.
        For(seen, ExecutionRawPath)[0].Snapshot.StatusCode.ShouldBe(BadTypeMismatch);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     <c>RawTags</c> and <c>Tags</c> may both name the same DataItem. The RawTags blob is the
    ///     deploy-time authority and its declared type wins; the dataItemId-keyed reference keeps
    ///     working alongside the RawPath, so a CLI session against a deployed driver still resolves.
    /// </summary>
    [Fact]
    public async Task Both_collections_together_bind_both_references_with_rawtags_winning_the_type()
    {
        var agent = CannedAgentClient.FromFixtures();
        var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);

        await driver.InitializeAsync(
            $$"""
              {"AgentUri":"{{AgentUri}}",
               "Tags":[{"FullName":"{{PositionDataItemId}}","DriverDataType":"String"}],
               "RawTags":[{"RawPath":"{{PositionRawPath}}","TagConfig":"{\"fullName\":\"{{PositionDataItemId}}\",\"driverDataType\":\"Float64\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
              """,
            Ct);

        var seen = Record(driver);
        await driver.SubscribeAsync(
            [PositionRawPath, PositionDataItemId], TimeSpan.FromMilliseconds(50), Ct);

        // The RawTags blob's Float64 beat the Tags entry's String — for BOTH references, because
        // there is one index and one definition per DataItem.
        For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
        For(seen, PositionDataItemId)[0].Snapshot.Value.ShouldBe(123.4567d);

        // …and a streamed chunk raises BOTH, once each.
        await agent.PumpAsync(
            Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));

        For(seen, PositionRawPath).Count.ShouldBe(2);
        For(seen, PositionDataItemId).Count.ShouldBe(2);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     One DataItem may back several authored raw tags (the same machine signal projected into two
    ///     places in the <c>/raw</c> tree). Each is its own OPC UA node and each must receive the
    ///     value — a one-to-one map would silently serve only whichever tag was authored last.
    /// </summary>
    [Fact]
    public async Task One_data_item_fans_out_to_every_raw_path_bound_to_it()
    {
        const string secondRawPath = "Plant/MTConnect/dev1/Mirror/Position";
        var (driver, agent) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
            Entry(secondRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
        var seen = Record(driver);

        await driver.SubscribeAsync(
            [PositionRawPath, secondRawPath], TimeSpan.FromMilliseconds(50), Ct);
        await agent.PumpAsync(
            Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));

        For(seen, PositionRawPath).Count.ShouldBe(2);
        For(seen, secondRawPath).Count.ShouldBe(2);
        For(seen, secondRawPath)[1].Snapshot.Value.ShouldBe(201.5d);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     A blob that names no DataItem id, or declares a <c>dataType</c> that is not a
    ///     <see cref="DriverDataType"/> (including the numerically-serialized-enum trap, where
    ///     <c>Enum.TryParse</c> would happily take <c>"8"</c> as the member with that ordinal), skips
    ///     that ONE tag — the rest of the driver deploys and serves normally.
    /// </summary>
    [Theory]
    [InlineData("""{"units":"MILLIMETER"}""")]
    [InlineData("""{"fullName":"dev1_pos","dataType":"Flot64"}""")]
    [InlineData("""{"fullName":"dev1_pos","dataType":"8"}""")]
    [InlineData("not json at all")]
    public async Task An_unusable_blob_skips_only_its_own_tag(string badBlob)
    {
        var (driver, _) = await DeployedAsync(
            new RawTagEntry("Plant/MTConnect/dev1/Bad", badBlob, false, "dev1"),
            Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));

        var values = await driver.ReadAsync(["Plant/MTConnect/dev1/Bad", PartCountRawPath], Ct);

        values[0].StatusCode.ShouldBe(BadNodeIdUnknown);
        values[1].StatusCode.ShouldBe(BadNoCommunication); // bound; the Agent reports it UNAVAILABLE

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     A redeploy that re-homes a tag in the <c>/raw</c> tree changes its RawPath. The driver must
    ///     rebind to the new one — and stop answering for the old one, which no longer names anything.
    ///     This is also the ordering pin: the binding is installed with the options, before the index
    ///     the pump republishes from, so a standing subscription can never be served through one
    ///     document's RawPaths against another's values.
    /// </summary>
    [Fact]
    public async Task Reinitialize_rebinds_raw_paths()
    {
        const string movedRawPath = "Plant/MTConnect/dev1/Spindle/Position";
        var (driver, agent) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
        var seen = Record(driver);

        await driver.SubscribeAsync([PositionRawPath, movedRawPath], TimeSpan.FromMilliseconds(50), Ct);
        For(seen, movedRawPath)[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);

        await driver.ReinitializeAsync(
            MergedConfig(Entry(movedRawPath, PositionDataItemId, nameof(DriverDataType.Float64))), Ct);

        // The restarted pump republishes every standing reference as its first act.
        await agent.PumpAsync(
            Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));

        For(seen, movedRawPath)[^1].Snapshot.Value.ShouldBe(201.5d);
        For(seen, PositionRawPath)[^1].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);

        await driver.ShutdownAsync(Ct);
    }

    /// <summary>
    ///     The factory is the production construction seam (<c>DriverFactoryRegistry</c> calls it with
    ///     the merged artifact config), so <c>RawTags</c> has to survive that path too — a driver whose
    ///     RawPaths only bind when constructed by hand is a driver that works in tests and nowhere else.
    /// </summary>
    [Fact]
    public void The_factory_binds_raw_tags_from_the_merged_config()
    {
        var driver = MTConnectDriverFactoryExtensions.CreateInstance(
            "mt1",
            MergedConfig(Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))));

        var bound = driver.EffectiveOptions.RawTags.ShouldHaveSingleItem();
        bound.RawPath.ShouldBe(PositionRawPath);
        bound.DeviceName.ShouldBe("dev1");
        bound.TagConfig.ShouldContain(PositionDataItemId);
    }

    /// <summary>
    ///     A subscribed RawPath with no <c>RawTags</c> entry behind it (an authoring slip, or a tag
    ///     whose blob would not map) must report Bad and keep serving every other reference — the
    ///     documented <see cref="EquipmentTagRefResolver{TDef}"/> posture: "a miss is a miss".
    /// </summary>
    [Fact]
    public async Task An_unmapped_raw_path_reports_bad_and_never_throws()
    {
        var (driver, agent) = await DeployedAsync(
            Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
        var seen = Record(driver);

        await driver.SubscribeAsync(
            [PositionRawPath, "Plant/MTConnect/dev1/Ghost"], TimeSpan.FromMilliseconds(50), Ct);

        For(seen, "Plant/MTConnect/dev1/Ghost")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);

        // The bound reference is unaffected — the miss did not poison the batch or the stream.
        await agent.PumpAsync(
            Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
        For(seen, PositionRawPath).Count.ShouldBe(2);

        await driver.ShutdownAsync(Ct);
    }
}
