using System.Net.Http;
using System.Xml.Linq;

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

/// <summary>
///     Reachability probe for a live MTConnect Agent (the <c>mtconnect/agent</c> container in
///     <c>Docker/docker-compose.yml</c>, or a real Agent on a machine tool). Reads
///     <c>MTCONNECT_AGENT_ENDPOINT</c> (default <c>http://10.100.0.35:5000</c> — the shared Docker
///     host) and issues ONE <c>GET {endpoint}/probe</c> at fixture construction. Each test checks
///     <see cref="SkipReason"/> and calls <c>Assert.Skip</c> when the Agent was unreachable, so a
///     dev box with no fixture running still passes <c>dotnet test</c> cleanly — the same pattern
///     as <c>ModbusSimulatorFixture</c> / <c>S7SimulatorFixture</c>.
/// </summary>
/// <remarks>
///     <para>
///         <b>The probe is an HTTP round-trip, not a TCP connect</b> — unlike the Modbus and S7
///         fixtures, whose protocols have nothing cheaper. An Agent's port accepts connections
///         before its device model is parsed, and a mis-authored <c>Devices.xml</c> produces an
///         Agent that answers TCP and then fails every request. A TCP-only probe would let the
///         whole suite run and fail confusingly rather than skip with an actionable message.
///     </para>
///     <para>
///         <b>The probe response is kept</b> (<see cref="ProbeDocument"/>) and is the source of
///         truth every assertion in this suite is written against. Nothing here may hard-code a
///         DataItem id: the seeded <c>Devices.xml</c> is expected to change, and a real Agent on a
///         real machine is a completely different model. Tests therefore say "for every DataItem
///         the Agent declares with category=EVENT and type PART_COUNT, the discovered variable must
///         be Int64" — a statement that survives any device model, including one with zero such
///         items (which self-skips rather than passing vacuously).
///     </para>
///     <para>
///         <b>A collection fixture, so the probe runs once per session</b> rather than once per
///         test: against a firewalled endpoint each attempt costs the full timeout.
///     </para>
/// </remarks>
public sealed class MTConnectAgentFixture : IAsyncDisposable
{
    /// <summary>The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.</summary>
    private const string DefaultEndpoint = "http://10.100.0.35:5000";

    private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";

    /// <summary>
    ///     Bound on the one-shot reachability probe. Deliberately generous relative to a TCP probe:
    ///     a cold Agent parses its device model on the first request, and a large real-world model
    ///     can take a second or two to serialize.
    /// </summary>
    private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);

    private static readonly string HowToStart =
        $"Start the fixture (docker compose -f Docker/docker-compose.yml up -d --wait) or point " +
        $"{EndpointEnvVar} at a live Agent, then re-run.";

    /// <summary>Gets the Agent's base URI, exactly as an authored <c>agentUri</c> would carry it.</summary>
    public string AgentUri { get; }

    /// <summary>Gets the skip reason when the Agent is unreachable or unusable; otherwise <c>null</c>.</summary>
    public string? SkipReason { get; }

    /// <summary>
    ///     Gets the Agent's own <c>/probe</c> response, parsed as XML — <c>null</c> exactly when
    ///     <see cref="SkipReason"/> is set. Tests cross-reference discovery output against this so
    ///     their assertions describe the Agent's declared model rather than a hard-coded fixture.
    /// </summary>
    public XDocument? ProbeDocument { get; }

    /// <summary>Initializes a new instance of the <see cref="MTConnectAgentFixture"/> class.</summary>
    public MTConnectAgentFixture()
    {
        AgentUri = (Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint).Trim().TrimEnd('/');

        if (!Uri.TryCreate(AgentUri, UriKind.Absolute, out var parsed) ||
            (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
        {
            SkipReason = $"{EndpointEnvVar} ('{AgentUri}') is not an absolute http(s) URI. {HowToStart}";

            return;
        }

        try
        {
            using var http = new HttpClient { Timeout = ProbeTimeout };
            using var response = http.GetAsync($"{AgentUri}/probe").GetAwaiter().GetResult();

            if (!response.IsSuccessStatusCode)
            {
                SkipReason =
                    $"MTConnect Agent at {AgentUri} answered /probe with HTTP {(int)response.StatusCode} " +
                    $"({response.ReasonPhrase}). {HowToStart}";

                return;
            }

            var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
            var document = XDocument.Parse(body);

            // An MTConnectError document (or anything else) served under a 2xx must skip, not run.
            // The suite's whole value is asserting against a real device model; running it against
            // a document that is not one produces confusing red rather than an honest skip.
            if (document.Root is null || document.Root.Name.LocalName != "MTConnectDevices")
            {
                SkipReason =
                    $"MTConnect Agent at {AgentUri} answered /probe with a <{document.Root?.Name.LocalName ?? "?"}> " +
                    $"document, not <MTConnectDevices>. {HowToStart}";

                return;
            }

            ProbeDocument = document;
        }
        catch (Exception ex)
        {
            SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
        }
    }

    /// <summary>
    ///     Gets every <c>DataItem</c> the Agent declares, flattened out of
    ///     <see cref="ProbeDocument"/> with the attributes the type inference consumes.
    /// </summary>
    /// <remarks>
    ///     Read straight off the XML with LINQ-to-XML rather than through the driver's own
    ///     <c>MTConnectProbeParser</c>: that parser is what several of these tests are checking, and
    ///     an assertion that ran the code under test to build its own expectation could only ever
    ///     prove the code agrees with itself. Matching is on local names because an MTConnect
    ///     document is namespaced and the namespace URI carries the schema version.
    /// </remarks>
    public IReadOnlyList<DeclaredDataItem> DeclaredDataItems =>
        ProbeDocument is null
            ? []
            : [.. ProbeDocument.Descendants()
                .Where(e => e.Name.LocalName == "DataItem")
                .Select(e => new DeclaredDataItem(
                    Id: (string?)e.Attribute("id") ?? string.Empty,
                    Name: (string?)e.Attribute("name"),
                    Category: (string?)e.Attribute("category") ?? string.Empty,
                    Type: (string?)e.Attribute("type") ?? string.Empty,
                    Units: (string?)e.Attribute("units"),
                    Representation: (string?)e.Attribute("representation"),
                    InComposition: e.Ancestors().Any(a => a.Name.LocalName == "Compositions"),
                    InAgentSelfModel: e.Ancestors().Any(a => a.Name.LocalName == "Agent")))
                .Where(d => d.Id.Length > 0)];

    /// <inheritdoc />
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

/// <summary>
///     One <c>DataItem</c> as the live Agent declares it in its <c>/probe</c> response — the
///     expectation side of every discovery assertion in this suite.
/// </summary>
/// <param name="Id">The <c>id</c> attribute: the observation correlation key, and what discovery must stamp as <c>FullName</c>.</param>
/// <param name="Name">The <c>name</c> attribute when present: cosmetic, and what discovery must use as the browse name.</param>
/// <param name="Category">The <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
/// <param name="Type">The <c>type</c> attribute in the probe document's UPPER_SNAKE spelling (<c>PART_COUNT</c>, not <c>PartCount</c>).</param>
/// <param name="Units">The <c>units</c> attribute when present.</param>
/// <param name="Representation">The <c>representation</c> attribute when present (<c>TIME_SERIES</c>, <c>DATA_SET</c>, …).</param>
/// <param name="InComposition">
///     <c>true</c> when this DataItem is declared under a <c>&lt;Compositions&gt;</c> element rather
///     than under a component's own <c>&lt;DataItems&gt;</c>. The driver's discovery walk recurses
///     <c>&lt;Components&gt;</c> only, so a composition-owned item is legitimately absent from the
///     browse tree; assertions about full coverage of the device model must exclude these or they
///     become a false red on any Agent whose model uses compositions.
/// </param>
/// <param name="InAgentSelfModel">
///     <c>true</c> when this DataItem belongs to the Agent's own <c>&lt;Agent&gt;</c> self-model
///     (connection status, observation update rate, adapter URI, …) rather than to a
///     <c>&lt;Device&gt;</c>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
///     move continuously <b>whether or not any adapter is attached</b> — so a "the stream delivered a
///     changed value" assertion that did not exclude these would pass against an Agent with no data
///     source at all, which is exactly the vacuous green this suite must not have.
/// </param>
public sealed record DeclaredDataItem(
    string Id,
    string? Name,
    string Category,
    string Type,
    string? Units,
    string? Representation,
    bool InComposition,
    bool InAgentSelfModel);

/// <summary>Collection definition so the reachability probe runs once per test session.</summary>
[Xunit.CollectionDefinition(Name)]
public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture<MTConnectAgentFixture>
{
    /// <summary>The collection name every test class in this suite joins.</summary>
    public const string Name = "MTConnectAgent";
}
