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

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

/// <summary>
///     Task 12 — <see cref="MTConnectDriver"/>'s <see cref="ITagDiscovery"/> half: the
///     <c>/probe</c> device model streamed into an <see cref="IAddressSpaceBuilder"/> as
///     Device → Component → DataItem, and the one-member browse opt-in
///     (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) the Wave-0 universal browser keys off.
/// </summary>
/// <remarks>
///     <para>
///         <b>The load-bearing assertion is <c>FullName == DataItem@id</c>.</b> The universal
///         browser commits a browsed leaf's <see cref="DriverAttributeInfo.FullName"/> straight into
///         <c>TagConfig.FullName</c>, and that is the key <c>ReadAsync</c> / the <c>/sample</c> pump
///         resolve an observation against (<see cref="MTConnectObservationIndex"/> is keyed by
///         <c>DataItem@id</c>). Streaming the browse <i>name</i> instead would produce a picker that
///         looks perfect and commits tags that can never report a value — every one of them stuck at
///         <c>BadWaitingForInitialData</c> behind a Healthy driver.
///     </para>
///     <para>
///         <b>The second load-bearing assertion is the type shape.</b> Discovery must stamp exactly
///         what <see cref="MTConnectDataTypeInference"/> returns, including the array shape — the
///         AdminUI typed editor calls the same function, so any local recomputation here makes the
///         picker and the editor disagree about the same data item. <c>PART_COUNT → Int64</c> is
///         called out on its own because the probe document's UPPER_SNAKE spelling is the case a
///         PascalCase-only match silently types as <c>String</c>.
///     </para>
/// </remarks>
public sealed class MTConnectDiscoverTests
{
    private const string AgentUri = "http://fixture-agent:5000";

    /// <summary>Every DataItem id the canned <c>Fixtures/probe.xml</c> declares.</summary>
    private static readonly string[] FixtureDataItemIds =
    [
        "dev1_avail",
        "dev1_pos",
        "dev1_vibration_ts",
        "dev1_partcount",
        "dev1_execution",
        "dev1_program",
        "dev1_system_cond",
    ];

    private static MTConnectDriverOptions Opts(string? deviceName = null) =>
        new()
        {
            AgentUri = AgentUri,
            DeviceName = deviceName,

            // Discovery is a pure read of the /probe device model: it must enumerate what the Agent
            // declares, NOT what an operator has already authored. Left empty on purpose so a driver
            // that streamed its authored tag table instead could not pass.
            Tags = [],
        };

    private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
        MTConnectDriverOptions? options = null,
        MTConnectProbeModel? probe = null,
        RecordingDriverLogger? logger = null)
    {
        var client = CannedAgentClient.FromFixtures();
        if (probe is not null)
        {
            client.Probe = probe;
        }

        return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client, logger), client);
    }

    private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
        MTConnectDriverOptions? options = null,
        MTConnectProbeModel? probe = null,
        RecordingDriverLogger? logger = null)
    {
        var (driver, client) = NewDriver(options, probe, logger);
        await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);

        return (driver, client);
    }

    private static async Task<CapturingBuilder> DiscoverAsync(MTConnectDriver driver)
    {
        var builder = new CapturingBuilder();
        await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken);

        return builder;
    }

    // ---- the plan's headline test ----

    /// <summary>
    ///     The whole contract in one place: the fixture's Device → Controller → Path tree reaches the
    ///     builder, the CONDITION data item is present under its own <c>id</c>, it types as
    ///     <see cref="DriverDataType.String"/>, and the driver declares the browse opt-in the
    ///     universal browser gates on.
    /// </summary>
    [Fact]
    public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev1_system_cond");
        cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
        driver.SupportsOnlineDiscovery.ShouldBeTrue();
        driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
    }

    // ---- tree shape ----

    /// <summary>
    ///     The folder tree mirrors the probe document exactly: one folder per Device (named by its
    ///     <c>name</c>), then one per nested Component to arbitrary depth, each under its own parent.
    /// </summary>
    [Fact]
    public async Task Discover_materialises_a_folder_per_device_and_nested_component()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Folders.Select(f => f.Path).ShouldBe(
            ["VMC-3Axis", "VMC-3Axis/controller", "VMC-3Axis/controller/path"], ignoreOrder: true);

        // Parentage, not just membership: a flat "three folders exist" assertion passes for a walker
        // that streamed all three onto the root builder.
        cap.Folders.Single(f => f.Path == "VMC-3Axis").ParentPath.ShouldBe(string.Empty);
        cap.Folders.Single(f => f.Path == "VMC-3Axis/controller").ParentPath.ShouldBe("VMC-3Axis");
        cap.Folders.Single(f => f.Path == "VMC-3Axis/controller/path").ParentPath.ShouldBe("VMC-3Axis/controller");
    }

    /// <summary>
    ///     <c>dev1_avail</c> hangs directly off <c>&lt;Device&gt;&lt;DataItems&gt;</c>, outside every
    ///     Component. A walker that only recursed <c>&lt;Components&gt;</c> would drop it silently, so
    ///     it is asserted to land in the <b>device</b> folder while the Path-owned items land in the
    ///     path folder.
    /// </summary>
    [Fact]
    public async Task Discover_places_device_level_data_items_in_the_device_folder()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Single(v => v.Attr.FullName == "dev1_avail").ParentPath.ShouldBe("VMC-3Axis");
        cap.Variables
            .Where(v => v.Attr.FullName != "dev1_avail")
            .ShouldAllBe(v => v.ParentPath == "VMC-3Axis/controller/path");
    }

    /// <summary>
    ///     Every DataItem the fixture declares is streamed exactly once, and every leaf's
    ///     <see cref="DriverAttributeInfo.FullName"/> is its <c>DataItem@id</c> — the read/subscribe
    ///     key, aligned with the browser's commit by construction.
    /// </summary>
    [Fact]
    public async Task Discover_streams_every_data_item_once_with_fullname_equal_to_its_id()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Count.ShouldBe(FixtureDataItemIds.Length);
        cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
    }

    /// <summary>
    ///     The <c>FullName</c>-is-the-<c>id</c> rule, asserted where the two can actually differ.
    /// </summary>
    /// <remarks>
    ///     <b>The fixture cannot prove this on its own</b> — its data items declare no <c>name</c>, so
    ///     <c>browseName</c> and <c>id</c> coincide and a driver that streamed the browse name as the
    ///     <c>FullName</c> would pass every fixture-based assertion in this file. This model gives
    ///     every data item a name that is disjoint from its id, so the two sets cannot be confused:
    ///     the commit key must be the id set, and the presentation must be the name set.
    /// </remarks>
    [Fact]
    public async Task Discover_fullname_is_the_id_even_when_the_data_item_declares_a_different_name()
    {
        var probe = ModelWith(
            new MTConnectDataItem("id_pos", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
            new MTConnectDataItem("id_count", "PartsMade", "EVENT", "PART_COUNT", null, null, null, null),
            new MTConnectDataItem("id_cond", "SystemHealth", "CONDITION", "SYSTEM", null, null, null, null),
            new MTConnectDataItem(
                "id_series", "Feed", "SAMPLE", "PATH_FEEDRATE", null, null, "TIME_SERIES", 10));

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        cap.Variables.Select(v => v.Attr.FullName)
            .ShouldBe(["id_pos", "id_count", "id_cond", "id_series"], ignoreOrder: true);
        cap.Variables.Select(v => v.BrowseName)
            .ShouldBe(["Xpos", "PartsMade", "SystemHealth", "Feed"], ignoreOrder: true);

        // …and the id-keyed metadata still lands on the right leaf once the two differ.
        cap.Variables.Single(v => v.Attr.FullName == "id_count").Attr.DriverDataType
            .ShouldBe(DriverDataType.Int64);
        cap.Variables.Single(v => v.Attr.IsAlarm).Attr.FullName.ShouldBe("id_cond");
        cap.Variables.Single(v => v.Attr.IsArray).Attr.FullName.ShouldBe("id_series");
    }

    // ---- type shape (must equal MTConnectDataTypeInference, not a local recomputation) ----

    /// <summary>
    ///     The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled
    ///     vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String.
    /// </summary>
    [Theory]
    [InlineData("dev1_pos", DriverDataType.Float64)]
    [InlineData("dev1_avail", DriverDataType.String)]
    [InlineData("dev1_execution", DriverDataType.String)]
    [InlineData("dev1_program", DriverDataType.String)]
    [InlineData("dev1_system_cond", DriverDataType.String)]
    public async Task Discover_stamps_the_inferred_data_type(string dataItemId, DriverDataType expected)
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Single(v => v.Attr.FullName == dataItemId).Attr.DriverDataType.ShouldBe(expected);
    }

    /// <summary>
    ///     <c>PART_COUNT</c> is the live-only bug this driver's inference was already fixed for once:
    ///     the probe document spells it UPPER_SNAKE, so a match written against the Streams
    ///     document's <c>PartCount</c> types every real agent's part counter as
    ///     <see cref="DriverDataType.String"/> while a PascalCase unit test stays green. Asserted at
    ///     the discovery seam because that is where the probe spelling is actually read.
    /// </summary>
    [Fact]
    public async Task Discover_types_the_upper_snake_PART_COUNT_event_as_Int64()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Single(v => v.Attr.FullName == "dev1_partcount").Attr.DriverDataType
            .ShouldBe(DriverDataType.Int64);
    }

    /// <summary>
    ///     A <c>TIME_SERIES</c> SAMPLE is an array whose length is the probe-declared
    ///     <c>sampleCount</c>. The shape comes from <see cref="MTConnectDataTypeInference"/>'s
    ///     returned record — recomputing it inline at this seam is what makes discovery and the
    ///     AdminUI editor disagree.
    /// </summary>
    [Fact]
    public async Task Discover_carries_the_time_series_array_shape_from_the_inference()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        var series = cap.Variables.Single(v => v.Attr.FullName == "dev1_vibration_ts").Attr;
        series.DriverDataType.ShouldBe(DriverDataType.Float64);
        series.IsArray.ShouldBeTrue();
        series.ArrayDim.ShouldBe(10u);
    }

    /// <summary>
    ///     A <c>DATA_SET</c> SAMPLE demotes to <see cref="DriverDataType.String"/> and stays scalar —
    ///     a rule that lives <b>only</b> inside the inference. An inline
    ///     "IsArray = representation == TIME_SERIES, DataType = category == SAMPLE ? Float64 : String"
    ///     at this seam would type it Float64 and go Bad on every parse, and no fixture-only test
    ///     would notice.
    /// </summary>
    [Fact]
    public async Task Discover_demotes_a_data_set_sample_to_string_per_the_inference()
    {
        var probe = ModelWith(
            new MTConnectDataItem(
                "dev9_toolset", null, "SAMPLE", "POSITION", null, "MILLIMETER", "DATA_SET", 4));

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_toolset").Attr;
        attr.DriverDataType.ShouldBe(DriverDataType.String);
        attr.IsArray.ShouldBeFalse();
        attr.ArrayDim.ShouldBeNull();
    }

    /// <summary>
    ///     <c>TIME_SERIES</c> is defined for SAMPLE only; on an EVENT the inference ignores it. Pins
    ///     the other half of "use the inference's answer, do not recompute the array shape".
    /// </summary>
    [Fact]
    public async Task Discover_ignores_time_series_on_a_non_sample_per_the_inference()
    {
        var probe = ModelWith(
            new MTConnectDataItem("dev9_msg", null, "EVENT", "MESSAGE", null, null, "TIME_SERIES", 8));

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_msg").Attr;
        attr.DriverDataType.ShouldBe(DriverDataType.String);
        attr.IsArray.ShouldBeFalse();
        attr.ArrayDim.ShouldBeNull();
    }

    // ---- the flags this seam owns ----

    /// <summary>
    ///     <see cref="DriverAttributeInfo.IsAlarm"/> is the caller's job — the inference deliberately
    ///     does not compute it. Exactly the CONDITION data item carries it.
    /// </summary>
    [Fact]
    public async Task Discover_marks_only_the_condition_data_item_as_an_alarm()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.Where(v => v.Attr.IsAlarm).Select(v => v.Attr.FullName).ShouldBe(["dev1_system_cond"]);
    }

    /// <summary>
    ///     MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
    ///     browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
    ///     from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
    ///     never inferred from a device model.
    /// </summary>
    [Fact]
    public async Task Discover_streams_every_leaf_view_only_and_not_historized()
    {
        var (driver, _) = await InitializedDriverAsync();

        var cap = await DiscoverAsync(driver);

        cap.Variables.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.ViewOnly);
        cap.Variables.ShouldAllBe(v => !v.Attr.IsHistorized);
        cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
    }

    // ---- browse names ----

    /// <summary>
    ///     The browse name is the DataItem's <c>name</c> when it declares one and its <c>id</c>
    ///     otherwise — the fixture's data items declare none, so the fallback is the normal case for
    ///     this protocol, not an edge case.
    /// </summary>
    [Fact]
    public async Task Discover_browse_name_prefers_the_data_item_name_and_falls_back_to_its_id()
    {
        var probe = ModelWith(
            new MTConnectDataItem("dev9_named", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
            new MTConnectDataItem("dev9_unnamed", null, "EVENT", "PROGRAM", null, null, null, null));

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        var named = cap.Variables.Single(v => v.Attr.FullName == "dev9_named");
        named.BrowseName.ShouldBe("Xpos");
        named.DisplayName.ShouldBe("Xpos");

        var unnamed = cap.Variables.Single(v => v.Attr.FullName == "dev9_unnamed");
        unnamed.BrowseName.ShouldBe("dev9_unnamed");

        // The browse name is presentation; the read key is not. Restated here because this is the
        // one test where the two legitimately differ.
        named.Attr.FullName.ShouldBe("dev9_named");
    }

    /// <summary>
    ///     A Device or Component with no <c>name</c> attribute falls back to its <c>id</c> rather
    ///     than materialising a blank folder.
    /// </summary>
    [Fact]
    public async Task Discover_folder_names_fall_back_to_the_id_when_no_name_is_declared()
    {
        var probe = new MTConnectProbeModel(
        [
            new MTConnectDevice(
                "dev9",
                null,
                [
                    new MTConnectComponent(
                        "dev9_axes",
                        null,
                        [],
                        [new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null)]),
                ],
                []),
        ]);

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        cap.Folders.Select(f => f.Path).ShouldBe(["dev9", "dev9/dev9_axes"], ignoreOrder: true);
        cap.Variables.Single().ParentPath.ShouldBe("dev9/dev9_axes");
    }

    // ---- DeviceName scoping ----

    /// <summary>
    ///     With no <c>DeviceName</c> the driver is agent-wide and every device's subtree is streamed.
    ///     The control for the scoping test below — without it, a discovery that dropped the second
    ///     device for an unrelated reason would look like correct scoping.
    /// </summary>
    [Fact]
    public async Task Discover_without_a_device_scope_streams_every_device()
    {
        var (driver, _) = await InitializedDriverAsync(probe: TwoDeviceModel());

        var cap = await DiscoverAsync(driver);

        cap.Folders.Where(f => f.ParentPath.Length == 0).Select(f => f.BrowseName)
            .ShouldBe(["VMC-3Axis", "Lathe-2Axis"], ignoreOrder: true);
        cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev2_avail");
    }

    /// <summary>
    ///     With <c>DeviceName</c> set, only that device's subtree is streamed.
    /// </summary>
    /// <remarks>
    ///     Belt-and-braces rather than dead code: the production client already narrows the request
    ///     to <c>{AgentUri}/{DeviceName}/probe</c>, so a conforming Agent answers with one device
    ///     anyway. But a non-conforming Agent (or a proxy that drops the path segment) that answered
    ///     agent-wide would otherwise publish every other machine's data items into an instance
    ///     scoped to one — and the operator's only evidence would be a picker full of ids they never
    ///     configured.
    /// </remarks>
    [Fact]
    public async Task Discover_with_a_device_scope_streams_only_that_device_subtree()
    {
        var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "Lathe-2Axis"), TwoDeviceModel());

        var cap = await DiscoverAsync(driver);

        cap.Folders.Select(f => f.Path).ShouldBe(["Lathe-2Axis"]);
        cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev2_avail"]);
    }

    /// <summary>
    ///     A device scope naming a device the Agent does not declare streams nothing. Kept explicit
    ///     because the alternative reading ("scope did not match, so stream everything") would hand a
    ///     mis-typed device name a picker showing the whole plant.
    /// </summary>
    [Fact]
    public async Task Discover_with_an_unmatched_device_scope_streams_nothing()
    {
        var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "not-a-device"), TwoDeviceModel());

        var cap = await DiscoverAsync(driver);

        cap.Folders.ShouldBeEmpty();
        cap.Variables.ShouldBeEmpty();
    }

    /// <summary>
    ///     A device with no <c>name</c> is addressable by its <c>id</c> — which is what a scoped
    ///     Agent URL would carry for it too.
    /// </summary>
    [Fact]
    public async Task Discover_device_scope_matches_a_nameless_device_by_id()
    {
        var probe = new MTConnectProbeModel(
        [
            new MTConnectDevice(
                "dev9", null, [], [new MTConnectDataItem("dev9_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
            new MTConnectDevice(
                "dev8", "Other", [], [new MTConnectDataItem("dev8_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
        ]);

        var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "dev9"), probe);

        var cap = await DiscoverAsync(driver);

        cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
    }

    /// <summary>
    ///     A configured <c>DeviceName</c> that matches nothing is an authoring error whose only
    ///     symptom is an empty picker. It must be reported at <b>Warning</b> — at Debug the operator
    ///     sees a browse that "worked" and a machine that apparently declares nothing, with no
    ///     evidence pointing at their typo.
    /// </summary>
    [Fact]
    public async Task Discover_warns_when_the_configured_device_scope_matches_nothing()
    {
        var logger = new RecordingDriverLogger();
        var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "no-such-device"), logger: logger);

        var cap = await DiscoverAsync(driver);

        cap.Variables.ShouldBeEmpty();
        logger.WarningsSnapshot()
            .ShouldContain(w => w.Contains("no-such-device", StringComparison.Ordinal));
    }

    /// <summary>
    ///     …and an agent-wide browse that finds nothing is NOT that warning: it is a different
    ///     statement (the Agent declares no devices) and must not cry scope-typo.
    /// </summary>
    [Fact]
    public async Task Discover_does_not_warn_about_scope_when_no_scope_is_configured()
    {
        var logger = new RecordingDriverLogger();
        var (driver, _) = await InitializedDriverAsync(probe: new MTConnectProbeModel([]), logger: logger);

        var cap = await DiscoverAsync(driver);

        cap.Variables.ShouldBeEmpty();
        logger.WarningsSnapshot().ShouldBeEmpty();
    }

    /// <summary>
    ///     A component declaring neither a <c>name</c> nor an <c>id</c> has no browse path at all,
    ///     and folding it in would open a folder called <c>""</c> — a blank segment in the path of
    ///     everything beneath it. It is skipped; its siblings are unaffected. (A component with a
    ///     blank id but a real name is fine — the id is only the fallback.)
    /// </summary>
    [Fact]
    public async Task Discover_skips_a_component_with_neither_a_name_nor_an_id()
    {
        var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
        var probe = new MTConnectProbeModel(
        [
            new MTConnectDevice(
                "dev9",
                "VMC",
                [
                    new MTConnectComponent("   ", "  ", [], [item]),
                    new MTConnectComponent("  ", "Axes", [], [item]),
                    new MTConnectComponent("dev9_ctrl", null, [], [item]),
                ],
                []),
        ]);

        var (driver, _) = await InitializedDriverAsync(probe: probe);

        var cap = await DiscoverAsync(driver);

        cap.Folders.Select(f => f.Path).ShouldBe(["VMC", "VMC/Axes", "VMC/dev9_ctrl"], ignoreOrder: true);
        cap.Folders.ShouldAllBe(f => !f.Path.Contains("//", StringComparison.Ordinal));
    }

    /// <summary>The same rule one level up: a device with no browse path is skipped, and said so.</summary>
    [Fact]
    public async Task Discover_skips_a_device_with_neither_a_name_nor_an_id()
    {
        var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
        var logger = new RecordingDriverLogger();
        var probe = new MTConnectProbeModel(
        [
            new MTConnectDevice(" ", "  ", [], [item]),
            new MTConnectDevice("dev9", "VMC", [], [item]),
        ]);

        var (driver, _) = await InitializedDriverAsync(probe: probe, logger: logger);

        var cap = await DiscoverAsync(driver);

        cap.Folders.Select(f => f.Path).ShouldBe(["VMC"]);
        logger.WarningsSnapshot()
            .ShouldContain(w => w.Contains("neither a name nor an id", StringComparison.Ordinal));
    }

    // ---- the probe-cache contract (anti-wedge) ----

    /// <summary>
    ///     Discovery is served from the cache <c>InitializeAsync</c> already populated — a browse
    ///     does not re-hit the Agent for a model it is holding.
    /// </summary>
    [Fact]
    public async Task Discover_serves_from_the_cached_probe_model()
    {
        var (driver, client) = await InitializedDriverAsync();
        client.ProbeCallCount.ShouldBe(1);

        await DiscoverAsync(driver);

        client.ProbeCallCount.ShouldBe(1);
    }

    /// <summary>
    ///     …and after <see cref="MTConnectDriver.FlushOptionalCachesAsync"/> has dropped that cache,
    ///     discovery re-fetches and still streams the full tree. This is the whole reason discovery
    ///     must go through <c>GetOrFetchProbeModelAsync</c> rather than reading the cached field: a
    ///     memory-budget flush must never leave browse permanently answering "this agent has no data
    ///     items".
    /// </summary>
    [Fact]
    public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
    {
        var (driver, client) = await InitializedDriverAsync();
        await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
        driver.CachedProbeModel.ShouldBeNull();

        var cap = await DiscoverAsync(driver);

        client.ProbeCallCount.ShouldBe(2);
        cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
        driver.CachedProbeModel.ShouldNotBeNull();
    }

    // ---- discovery never touches the subscription surface ----

    /// <summary>
    ///     Discovery is a pure read of the device model: it does not write the shared observation
    ///     index (the <c>/sample</c> pump is its sole writer) and it does not issue a <c>/current</c>.
    ///     A discovery that folded a snapshot into the index would be a second writer racing the
    ///     pump, rolling subscribed values backwards.
    /// </summary>
    [Fact]
    public async Task Discover_does_not_touch_the_observation_index_or_call_current()
    {
        var options = new MTConnectDriverOptions
        {
            AgentUri = AgentUri,
            Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
        };

        var (driver, client) = await InitializedDriverAsync(options);
        var index = driver.ObservationIndex;
        var indexed = index.Count;
        var currentCalls = client.CurrentCallCount;

        await DiscoverAsync(driver);

        ReferenceEquals(driver.ObservationIndex, index).ShouldBeTrue();
        driver.ObservationIndex.Count.ShouldBe(indexed);
        client.CurrentCallCount.ShouldBe(currentCalls);
    }

    // ---- failure posture: loud, never a silently-empty tree ----

    /// <summary>
    ///     Discovery before <c>InitializeAsync</c> throws rather than streaming an empty tree.
    /// </summary>
    /// <remarks>
    ///     <b>This is the deliberate opposite of <see cref="MTConnectDriver.ReadAsync"/>'s
    ///     never-throw posture, and the difference is what the caller can do with the answer.</b> A
    ///     read has per-reference Bad status codes to report a failure <i>in-band</i>; a discovery
    ///     stream has no such channel — an empty tree is indistinguishable from "this Agent declares
    ///     no data items", which is the #485 shape (failure wearing success's clothes) and would read
    ///     to an operator in the browse picker as a working connection to an empty machine. Both
    ///     production callers handle the throw: the universal browser surfaces it as a failed browse,
    ///     and <c>DriverInstanceActor</c> logs it, publishes an empty node set, and does NOT retry
    ///     (retrying is an <c>UntilStable</c> behaviour; this driver is <c>Once</c>) — which today
    ///     reaches nothing anyway, since <c>DriverHostActor</c>'s discovered-node injection is
    ///     dormant in v3. The browse path is the consumer this posture is chosen for.
    /// </remarks>
    [Fact]
    public async Task Discover_before_initialize_throws_and_streams_nothing()
    {
        var (driver, client) = NewDriver();
        var builder = new CapturingBuilder();

        await Should.ThrowAsync<InvalidOperationException>(
            () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));

        builder.Folders.ShouldBeEmpty();
        builder.Variables.ShouldBeEmpty();
        client.ProbeCallCount.ShouldBe(0);
    }

    /// <summary>Same posture after <c>ShutdownAsync</c> — a stopped driver has no model to browse.</summary>
    [Fact]
    public async Task Discover_after_shutdown_throws_and_streams_nothing()
    {
        var (driver, _) = await InitializedDriverAsync();
        await driver.ShutdownAsync(TestContext.Current.CancellationToken);

        var builder = new CapturingBuilder();

        await Should.ThrowAsync<InvalidOperationException>(
            () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));

        builder.Variables.ShouldBeEmpty();
    }

    /// <summary>
    ///     An Agent that fails the <c>/probe</c> a discovery needs fails the discovery — it does not
    ///     degrade to an empty tree. Same #485 reasoning as the un-initialized case.
    /// </summary>
    [Fact]
    public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
    {
        var (driver, client) = await InitializedDriverAsync();
        await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
        client.ProbeFailure = new HttpRequestException("agent down");

        var builder = new CapturingBuilder();

        await Should.ThrowAsync<HttpRequestException>(
            () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));

        builder.Variables.ShouldBeEmpty();
    }

    /// <summary>Caller cancellation propagates, as it does on every other seam of this driver.</summary>
    [Fact]
    public async Task Discover_propagates_caller_cancellation()
    {
        var (driver, _) = await InitializedDriverAsync();
        await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);

        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(
            () => driver.DiscoverAsync(new CapturingBuilder(), cts.Token));
    }

    /// <summary>A null builder is a caller bug — the one thing this method is loud about up front.</summary>
    [Fact]
    public async Task Discover_with_a_null_builder_throws_ArgumentNullException()
    {
        var (driver, _) = await InitializedDriverAsync();

        await Should.ThrowAsync<ArgumentNullException>(
            () => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken));
    }

    // ---- the browse opt-in ----

    /// <summary>
    ///     The universal browser's <c>CanBrowse</c> constructs a throwaway instance purely to read
    ///     these two members, so they must answer on an <b>un-initialized</b> driver without
    ///     dialling anything.
    /// </summary>
    [Fact]
    public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting()
    {
        var (driver, client) = NewDriver();

        driver.ShouldBeAssignableTo<ITagDiscovery>();
        driver.SupportsOnlineDiscovery.ShouldBeTrue();
        driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
        client.ProbeCallCount.ShouldBe(0);
        client.CurrentCallCount.ShouldBe(0);
    }

    // ---- helpers ----

    /// <summary>A one-device model whose single Path component owns <paramref name="dataItems"/>.</summary>
    private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) =>
        new(
        [
            new MTConnectDevice(
                "dev9",
                "Probe9",
                [new MTConnectComponent("dev9_path", "path", [], dataItems)],
                []),
        ]);

    /// <summary>
    ///     The canned fixture's device plus a second, independent one — the multi-device Agent the
    ///     <c>DeviceName</c> scope exists for.
    /// </summary>
    private static MTConnectProbeModel TwoDeviceModel()
    {
        var fixture = MTConnectProbeParser.Parse(File.ReadAllText("Fixtures/probe.xml"));
        var second = new MTConnectDevice(
            "dev2",
            "Lathe-2Axis",
            [],
            [new MTConnectDataItem("dev2_avail", null, "EVENT", "AVAILABILITY", null, null, null, null)]);

        return new MTConnectProbeModel([.. fixture.Devices, second]);
    }
}
