using System.Diagnostics;
using System.Net;
using System.Text;
using Shouldly;
using Xunit;

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

/// <summary>
///     Task 6 — <c>/probe</c> parse into the neutral <see cref="MTConnectProbeModel"/>, plus the
///     <see cref="MTConnectAgentClient.ProbeAsync"/> leg that feeds it (URL composition + the
///     per-op deadline), all exercised with no socket.
/// </summary>
public sealed class MTConnectProbeParseTests
{
    private const string ProbeFixturePath = "Fixtures/probe.xml";

    /// <summary>Every DataItem id the committed fixture declares — all seven, at every depth.</summary>
    private static readonly string[] AllFixtureDataItemIds =
    [
        "dev1_avail",          // sits directly on <Device><DataItems>, NOT under a component
        "dev1_pos",
        "dev1_vibration_ts",
        "dev1_partcount",
        "dev1_execution",
        "dev1_program",
        "dev1_system_cond"     // CONDITION
    ];

    // ---------------------------------------------------------------- parse: the committed fixture

    [Fact]
    public async Task Probe_parse_yields_nested_components_and_all_dataitems()
    {
        var xml = await File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);

        var model = MTConnectProbeParser.Parse(xml);

        model.Devices.ShouldHaveSingleItem();
        var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
        ids.ShouldContain("dev1_system_cond");
        model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
    }

    [Fact]
    public async Task Probe_parse_finds_every_dataitem_including_the_device_level_one()
    {
        var model = MTConnectProbeParser.Parse(await ReadFixtureAsync());

        var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();

        // All seven — a walker that only recurses <Components> silently drops dev1_avail.
        ids.ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
        model.Devices[0].DataItems.Select(d => d.Id).ShouldBe(["dev1_avail"]);
    }

    [Fact]
    public async Task Probe_parse_reads_the_device_identity()
    {
        var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];

        device.Id.ShouldBe("dev1");
        device.Name.ShouldBe("VMC-3Axis");
    }

    [Fact]
    public async Task Probe_parse_preserves_the_component_nesting_chain()
    {
        var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];

        // Device -> Controller -> Path. The element names are the component TYPES (there is no
        // literal <Component> element in MTConnect Devices XML).
        var controller = device.Components.ShouldHaveSingleItem();
        controller.Id.ShouldBe("dev1_controller");
        controller.Name.ShouldBe("controller");
        controller.DataItems.ShouldBeEmpty();

        var path = controller.Components.ShouldHaveSingleItem();
        path.Id.ShouldBe("dev1_path");
        path.Name.ShouldBe("path");
        path.Components.ShouldBeEmpty();
        path.DataItems.Select(d => d.Id).ShouldBe(
            ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond"]);
    }

    [Fact]
    public async Task Probe_parse_round_trips_every_dataitem_attribute()
    {
        var items = MTConnectProbeParser.Parse(await ReadFixtureAsync())
            .Devices[0].AllDataItems().ToDictionary(d => d.Id);

        var pos = items["dev1_pos"];
        pos.Category.ShouldBe("SAMPLE");
        pos.Type.ShouldBe("POSITION");
        pos.SubType.ShouldBe("ACTUAL");
        pos.Units.ShouldBe("MILLIMETER");
        pos.Representation.ShouldBeNull();
        pos.SampleCount.ShouldBeNull();

        var timeSeries = items["dev1_vibration_ts"];
        timeSeries.Category.ShouldBe("SAMPLE");
        timeSeries.Type.ShouldBe("PATH_FEEDRATE");
        timeSeries.Representation.ShouldBe("TIME_SERIES");
        timeSeries.SampleCount.ShouldBe(10);
        timeSeries.Units.ShouldBe("MILLIMETER/SECOND");

        var condition = items["dev1_system_cond"];
        condition.Category.ShouldBe("CONDITION");
        condition.Type.ShouldBe("SYSTEM");

        var availability = items["dev1_avail"];
        availability.Category.ShouldBe("EVENT");
        availability.Type.ShouldBe("AVAILABILITY");
        availability.Name.ShouldBeNull();
        availability.SubType.ShouldBeNull();
        availability.Units.ShouldBeNull();
        availability.Representation.ShouldBeNull();
        availability.SampleCount.ShouldBeNull();
    }

    [Fact]
    public async Task Probe_parse_from_a_stream_matches_the_string_overload()
    {
        await using var stream = File.OpenRead(ProbeFixturePath);

        var fromStream = MTConnectProbeParser.Parse(stream);

        // Records compare their IReadOnlyList members by reference, so compare the flattened
        // content rather than the graphs themselves.
        var fromString = MTConnectProbeParser.Parse(await ReadFixtureAsync());
        fromStream.Devices.Select(d => d.Id).ShouldBe(fromString.Devices.Select(d => d.Id));
        fromStream.Devices[0].AllDataItems().ShouldBe(fromString.Devices[0].AllDataItems());
        fromStream.Devices[0].Components[0].Components[0].Id.ShouldBe("dev1_path");
    }

    // ------------------------------------------------------- parse: not shaped to the 1.3 fixture

    [Theory]
    [InlineData("urn:mtconnect.org:MTConnectDevices:1.3")]
    [InlineData("urn:mtconnect.org:MTConnectDevices:1.5")]
    [InlineData("urn:mtconnect.org:MTConnectDevices:2.0")]
    [InlineData("")] // no namespace at all
    public void Probe_parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
    {
        var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
        var xml = $"""
                   <?xml version="1.0" encoding="UTF-8"?>
                   <MTConnectDevices{xmlns}>
                     <Header instanceId="1" bufferSize="131072"/>
                     <Devices>
                       <Device id="d" name="D">
                         <DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
                       </Device>
                     </Devices>
                   </MTConnectDevices>
                   """;

        var model = MTConnectProbeParser.Parse(xml);

        model.Devices.ShouldHaveSingleItem().AllDataItems().Select(d => d.Id).ShouldBe(["d_avail"]);
    }

    [Fact]
    public void Probe_parse_recurses_components_to_arbitrary_depth()
    {
        // Four levels deep, and each level carries its own data item — deeper than the fixture, and
        // with component element names the parser has never seen.
        const string Xml = """
                           <MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
                             <Devices>
                               <Device id="d">
                                 <DataItems><DataItem id="l0" category="EVENT" type="AVAILABILITY"/></DataItems>
                                 <Components>
                                   <Axes id="ax">
                                     <DataItems><DataItem id="l1" category="EVENT" type="PART_COUNT"/></DataItems>
                                     <Components>
                                       <Linear id="x">
                                         <DataItems><DataItem id="l2" category="SAMPLE" type="POSITION"/></DataItems>
                                         <Components>
                                           <Motor id="m">
                                             <DataItems><DataItem id="l3" category="SAMPLE" type="TEMPERATURE"/></DataItems>
                                           </Motor>
                                         </Components>
                                       </Linear>
                                     </Components>
                                   </Axes>
                                 </Components>
                               </Device>
                             </Devices>
                           </MTConnectDevices>
                           """;

        var device = MTConnectProbeParser.Parse(Xml).Devices[0];

        device.AllDataItems().Select(d => d.Id).ShouldBe(["l0", "l1", "l2", "l3"]);
        device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
    }

    [Fact]
    public void Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace()
    {
        // The shape the parser's ignore-the-namespace rule exists for, and the reason attributes are
        // read unqualified: the vendor component carries its OWN namespace prefix, while its
        // standard <DataItems>/<DataItem> children inherit the DEFAULT MTConnect namespace. Matching
        // components on a fully-qualified XName would silently drop <x:Pump> and every data item
        // beneath it, leaving the browse tree short with no error anywhere.
        const string Xml = """
                           <MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
                                             xmlns:x="urn:vendor:acme:1.0">
                             <Devices>
                               <Device id="d" name="D">
                                 <DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
                                 <Components>
                                   <x:Pump id="pump1" name="pump">
                                     <DataItems>
                                       <DataItem id="pump_pressure" category="SAMPLE" type="PRESSURE" units="PASCAL"/>
                                     </DataItems>
                                     <Components>
                                       <x:Impeller id="imp1">
                                         <DataItems>
                                           <DataItem id="imp_rpm" category="SAMPLE" type="ROTARY_VELOCITY"/>
                                         </DataItems>
                                       </x:Impeller>
                                     </Components>
                                   </x:Pump>
                                 </Components>
                               </Device>
                             </Devices>
                           </MTConnectDevices>
                           """;

        var device = MTConnectProbeParser.Parse(Xml).Devices[0];

        var pump = device.Components.ShouldHaveSingleItem();
        pump.Id.ShouldBe("pump1");
        pump.Name.ShouldBe("pump");
        pump.Components.ShouldHaveSingleItem().Id.ShouldBe("imp1");
        device.AllDataItems().Select(d => d.Id).ShouldBe(["d_avail", "pump_pressure", "imp_rpm"]);
        device.AllDataItems().Single(d => d.Id == "pump_pressure").Units.ShouldBe("PASCAL");
    }

    [Fact]
    public void Probe_parse_reads_every_device_of_a_multi_device_agent()
    {
        const string Xml = """
                           <MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
                             <Devices>
                               <Device id="agent" name="Agent">
                                 <DataItems><DataItem id="agent_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
                               </Device>
                               <Device id="d1" name="One">
                                 <DataItems><DataItem id="d1_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
                               </Device>
                             </Devices>
                           </MTConnectDevices>
                           """;

        var model = MTConnectProbeParser.Parse(Xml);

        model.Devices.Select(d => d.Id).ShouldBe(["agent", "d1"]);
    }

    // ------------------------------------------------------------------ parse: fails loudly, never
    //                                                                     a silently-empty model

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("not xml at all")]
    [InlineData("<MTConnectDevices><Devices>")] // truncated mid-document
    public void Probe_parse_throws_on_malformed_xml(string xml)
    {
        Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
    }

    [Fact]
    public void Probe_parse_throws_on_a_non_mtconnect_document()
    {
        var ex = Should.Throw<InvalidDataException>(
            () => MTConnectProbeParser.Parse("<html><body>404 Not Found</body></html>"));

        ex.Message.ShouldContain("MTConnectDevices");
        ex.Message.ShouldContain("html");
    }

    [Fact]
    public void Probe_parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
    {
        // A real Agent answers an unknown device path with an MTConnectError document under HTTP 200.
        const string Xml = """
                           <MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
                             <Header instanceId="1"/>
                             <Errors>
                               <Error errorCode="NO_DEVICE">Could not find the device 'nope'</Error>
                             </Errors>
                           </MTConnectError>
                           """;

        var ex = Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));

        ex.Message.ShouldContain("NO_DEVICE");
        ex.Message.ShouldContain("Could not find the device 'nope'");
    }

    [Theory]
    [InlineData("<MTConnectDevices><Header instanceId=\"1\"/></MTConnectDevices>")]         // no <Devices>
    [InlineData("<MTConnectDevices><Header instanceId=\"1\"/><Devices/></MTConnectDevices>")] // empty <Devices>
    public void Probe_parse_throws_when_the_document_declares_no_devices(string xml)
    {
        Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
    }

    [Theory]
    // DataItem missing its required id / category / type, and a garbage sampleCount.
    [InlineData("<DataItem category=\"EVENT\" type=\"AVAILABILITY\"/>")]
    [InlineData("<DataItem id=\"x\" type=\"AVAILABILITY\"/>")]
    [InlineData("<DataItem id=\"x\" category=\"EVENT\"/>")]
    [InlineData("<DataItem id=\"x\" category=\"SAMPLE\" type=\"POSITION\" sampleCount=\"lots\"/>")]
    public void Probe_parse_throws_on_a_malformed_dataitem(string dataItemXml)
    {
        var xml = $"""
                   <MTConnectDevices>
                     <Devices><Device id="d"><DataItems>{dataItemXml}</DataItems></Device></Devices>
                   </MTConnectDevices>
                   """;

        Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
    }

    [Fact]
    public void Probe_parse_throws_when_a_device_has_no_id()
    {
        const string Xml = """
                           <MTConnectDevices>
                             <Devices><Device name="nameless"/></Devices>
                           </MTConnectDevices>
                           """;

        Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
    }

    [Fact]
    public void Probe_parse_throws_when_a_component_has_no_id()
    {
        const string Xml = """
                           <MTConnectDevices>
                             <Devices><Device id="d"><Components><Controller name="c"/></Components></Device></Devices>
                           </MTConnectDevices>
                           """;

        Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
    }

    // ---------------------------------------------------------------- MTConnectAgentClient.ProbeAsync

    [Fact]
    public async Task ProbeAsync_requests_the_agent_probe_path_and_returns_the_parsed_model()
    {
        var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
        using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);

        var model = await client.ProbeAsync(TestContext.Current.CancellationToken);

        handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
        model.Devices.ShouldHaveSingleItem().Id.ShouldBe("dev1");
    }

    [Theory]
    [InlineData("http://agent:5000", null, "http://agent:5000/probe")]
    [InlineData("http://agent:5000/", null, "http://agent:5000/probe")]
    [InlineData("http://agent:5000", "VMC-3Axis", "http://agent:5000/VMC-3Axis/probe")]
    [InlineData("http://agent:5000/", "VMC 3Axis", "http://agent:5000/VMC%203Axis/probe")]
    public async Task ProbeAsync_scopes_the_request_to_the_configured_device_name(
        string agentUri, string? deviceName, string expectedUri)
    {
        var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
        using var client = new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = agentUri, DeviceName = deviceName }, handler);

        await client.ProbeAsync(TestContext.Current.CancellationToken);

        handler.LastRequestUri!.AbsoluteUri.ShouldBe(expectedUri);
    }

    [Fact]
    public async Task ProbeAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
    {
        // The R2-01 frozen-peer lesson: a wedged agent must surface as a cancelled call, not a hang.
        var handler = StubHandler.Hanging();
        using var client = new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }, handler);

        var started = Stopwatch.StartNew();
        var ex = await Should.ThrowAsync<TimeoutException>(
            async () => await client.ProbeAsync(TestContext.Current.CancellationToken));

        // A deadline hit must NOT be reported as a plain cancellation — the caller never cancelled.
        ex.ShouldNotBeAssignableTo<OperationCanceledException>();
        ex.Message.ShouldContain("http://agent:5000/probe");
        started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
    }

    [Fact]
    public async Task ProbeAsync_honours_the_callers_cancellation_token()
    {
        var handler = StubHandler.Hanging();
        using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(async () => await client.ProbeAsync(cts.Token));
    }

    [Fact]
    public async Task ProbeAsync_throws_rather_than_returning_an_empty_model_when_the_agent_errors()
    {
        var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
        using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);

        await Should.ThrowAsync<HttpRequestException>(
            async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Ctor_rejects_a_non_positive_request_timeout(int requestTimeoutMs)
    {
        // An operator-authorable 0 must never mean "wait forever" (arch-review 01/S-6).
        Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = requestTimeoutMs }));
    }

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("not a uri")]
    public void Ctor_rejects_an_unusable_agent_uri(string agentUri)
    {
        Should.Throw<ArgumentException>(() => new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = agentUri }));
    }

    [Fact]
    public void Ctor_opens_no_connection()
    {
        // The universal browser's throwaway-instance CanBrowse pattern depends on this.
        var handler = StubHandler.Hanging();

        using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);

        handler.RequestCount.ShouldBe(0);
    }

    private static Task<string> ReadFixtureAsync() =>
        File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);

    /// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
    private sealed class StubHandler : HttpMessageHandler
    {
        private readonly string? _body;
        private readonly HttpStatusCode _status;
        private readonly bool _hang;

        private StubHandler(string? body, HttpStatusCode status, bool hang)
        {
            _body = body;
            _status = status;
            _hang = hang;
        }

        public Uri? LastRequestUri { get; private set; }

        public int RequestCount { get; private set; }

        public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
            new(body, status, hang: false);

        public static StubHandler Hanging() => new(null, HttpStatusCode.OK, hang: true);

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            LastRequestUri = request.RequestUri;
            RequestCount++;

            if (_hang)
            {
                await Task.Delay(Timeout.Infinite, cancellationToken);
            }

            return new HttpResponseMessage(_status)
            {
                Content = new StringContent(_body ?? string.Empty, Encoding.UTF8, "text/xml")
            };
        }
    }
}
