using System.Globalization;
using System.Xml;
using System.Xml.Linq;

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

/// <summary>
///     Turns an MTConnect Agent <c>MTConnectStreams</c> document — a <c>/current</c> snapshot or one
///     chunk of a <c>/sample</c> multipart stream — into the neutral
///     <see cref="MTConnectStreamsResult"/>. Deliberately a pure, socket-free static so the whole
///     parse is unit-testable against canned fixtures; chunk framing lives in
///     <see cref="MTConnectAgentClient"/> and hands this parser one chunk's XML at a time.
/// </summary>
/// <remarks>
///     <para>
///         <b>Why hand-rolled rather than TrakHound.</b> Same finding as
///         <see cref="MTConnectProbeParser"/>: the packages pinned by Task 0 ship no XML document
///         formatter, and TrakHound's streaming client offers no socket-free entry point. Both
///         package references were dropped in Task 7; the driver depends only on the BCL.
///     </para>
///     <para>
///         <b>The Streams document is shaped nothing like the Devices document</b>, and three
///         differences drive this parser's design.
///     </para>
///     <list type="number">
///         <item>
///             <description>
///                 <b>The observation element's name is the data item's type in PascalCase</b>
///                 (<c>Position</c>, <c>PartCount</c>, <c>Execution</c>) — not the probe's
///                 UPPER_SNAKE <c>POSITION</c> / <c>PART_COUNT</c>. The standard defines hundreds of
///                 types and vendors add more, so matching a fixed set of element names would drop
///                 observations silently. <b>Every</b> element child of <c>&lt;Samples&gt;</c>,
///                 <c>&lt;Events&gt;</c> and <c>&lt;Condition&gt;</c> is therefore an observation,
///                 keyed by its <c>dataItemId</c> attribute — the only identity the probe model and
///                 the streams document actually share.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <b>A CONDITION observation's value is its ELEMENT NAME, not its text.</b>
///                 <c>&lt;Normal/&gt;</c> / <c>&lt;Warning/&gt;</c> / <c>&lt;Fault/&gt;</c> /
///                 <c>&lt;Unavailable/&gt;</c> carry the state in the name and are typically empty;
///                 where text is present it is the operator-facing message, not the state. Reading
///                 the text would report every condition as an empty string.
///                 <c>&lt;Unavailable/&gt;</c> is normalized to the literal
///                 <see cref="UnavailableSentinel"/> so a no-comms condition lands on exactly the
///                 same sentinel as a Sample/Event whose text is <c>UNAVAILABLE</c> — the
///                 observation index maps that one token to bad quality.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <b>Timestamps are normalized to UTC explicitly.</b> MTConnect timestamps are
///                 ISO-8601 Zulu, but a bare <c>DateTime.Parse</c> yields <c>Local</c> or
///                 <c>Unspecified</c> depending on machine settings — and this value becomes the
///                 OPC UA SourceTimestamp, so a wrong <see cref="DateTimeKind"/> shifts every
///                 timestamp by the host's UTC offset with nothing to show for it.
///             </description>
///         </item>
///     </list>
///     <para>
///         <b>Failure posture.</b> A malformed document, a non-<c>MTConnectStreams</c> root (an Agent
///         <c>MTConnectError</c> served under HTTP 200 included), an unusable header, or an
///         observation missing its <c>dataItemId</c>/<c>timestamp</c> all throw
///         <see cref="InvalidDataException"/>. A document with a valid header and <i>zero</i>
///         observations, by contrast, is entirely legal — <c>/sample</c> chunks are deltas and an
///         idle Agent's keep-alive is an observation-free document — so it parses to an empty
///         observation list rather than throwing.
///     </para>
/// </remarks>
internal static class MTConnectStreamsParser
{
    /// <summary>
    ///     The single token that means "the Agent has no value for this data item". Sample/Event
    ///     observations carry it as text; a CONDITION carries it as the element name
    ///     <c>&lt;Unavailable/&gt;</c> and is normalized onto this spelling here.
    /// </summary>
    private const string UnavailableSentinel = "UNAVAILABLE";

    private const string ConditionContainer = "Condition";

    /// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
    private const string Subject = "streams response";

    /// <summary>How those messages name the header element.</summary>
    private const string HeaderOwner = "the <Header>";

    /// <summary>
    ///     The three elements whose element children are observations. A ComponentStream may carry
    ///     any subset of them, including none.
    /// </summary>
    private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];

    /// <summary>Parses a <c>/current</c> response, or one <c>/sample</c> chunk, held as a string.</summary>
    /// <param name="xml">The raw <c>MTConnectStreams</c> XML document.</param>
    /// <exception cref="InvalidDataException">
    ///     The payload is empty, not well-formed XML, not an <c>MTConnectStreams</c> document (an
    ///     Agent <c>MTConnectError</c> included — Agents answer a bad request with one under HTTP
    ///     200), carries no usable <c>&lt;Header&gt;</c>, or carries a malformed observation.
    /// </exception>
    public static MTConnectStreamsResult Parse(string xml)
    {
        if (string.IsNullOrWhiteSpace(xml))
        {
            throw new InvalidDataException(
                "MTConnect streams response was empty; expected an MTConnectStreams XML document.");
        }

        XDocument document;
        try
        {
            // A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
            // trailing line break; XmlReader rejects either ahead of the XML declaration.
            document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
        }
        catch (XmlException ex)
        {
            throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
        }

        return Build(document);
    }

    /// <summary>Parses a <c>/current</c> response read from a stream.</summary>
    /// <param name="stream">A stream positioned at the start of the XML document.</param>
    /// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
    public static MTConnectStreamsResult Parse(Stream stream)
    {
        ArgumentNullException.ThrowIfNull(stream);

        XDocument document;
        try
        {
            document = XDocument.Load(stream);
        }
        catch (XmlException ex)
        {
            throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
        }

        return Build(document);
    }

    private static MTConnectStreamsResult Build(XDocument document)
    {
        var root = document.Root
                   ?? throw new InvalidDataException("MTConnect streams response has no root element.");

        if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
        {
            throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
        }

        var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
                     ?? throw new InvalidDataException(
                         "MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");

        return new MTConnectStreamsResult(
            MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
            MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
            MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
            ReadObservations(root));
    }

    /// <summary>
    ///     Collects every observation in the document, in Agent-supplied order. Containers are found
    ///     by descending the whole <c>&lt;Streams&gt;</c> subtree rather than by walking a fixed
    ///     DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
    ///     hide a device's observations.
    /// </summary>
    private static IReadOnlyList<MTConnectObservation> ReadObservations(XElement root)
    {
        var observations = new List<MTConnectObservation>();

        foreach (var container in root.Descendants().Where(IsObservationContainer))
        {
            var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
            foreach (var element in container.Elements())
            {
                observations.Add(ReadObservation(element, isCondition));
            }
        }

        return observations;
    }

    private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
    {
        var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);

        return new MTConnectObservation(
            dataItemId,
            isCondition ? ConditionState(element) : element.Value.Trim(),
            ReadTimestampUtc(element, dataItemId),

            // A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
            // text read above concatenates them into nonsense ("12" for two entries). Flagged here
            // because this is the only layer that can still see the distinction — see
            // MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
            // the element NAME, so child content cannot corrupt it, and flagging one would throw
            // away a perfectly well-determined state.
            IsStructured: !isCondition && element.HasElements);
    }

    /// <summary>
    ///     A condition's state is its element name. <c>Unavailable</c> is normalized to the
    ///     <see cref="UnavailableSentinel"/> spelling so downstream quality mapping has exactly one
    ///     token to recognize; every other state is reported as-named (<c>Normal</c>, <c>Warning</c>,
    ///     <c>Fault</c>, and any vendor state).
    /// </summary>
    private static string ConditionState(XElement element)
    {
        var state = element.Name.LocalName;

        return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
    }

    private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
    {
        var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);

        if (!DateTime.TryParse(
                raw,
                CultureInfo.InvariantCulture,
                DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
                out var parsed))
        {
            throw new InvalidDataException(
                $"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
        }

        // AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
        // quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
        return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
    }

    private static bool IsObservationContainer(XElement element) =>
        ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));

}
