using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
///     an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
///     timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
///     pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
/// </summary>
/// <remarks>
///     <para>
///         <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
///         value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
///         after <see cref="MTConnectStreamsParser"/> normalizes the <c>&lt;Unavailable/&gt;</c>
///         element name — as a CONDITION's value on that same exact spelling. It maps to
///         <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
///         data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
///         the parser guarantees; a case-insensitive match would swallow a legitimate free-text
///         EVENT whose value happens to read "Unavailable".
///     </para>
///     <para>
///         <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
///         that boundary on account of observation content. Every unusable observation degrades to a
///         Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
///         always see <i>when</i> the driver last heard about the tag even when it cannot show
///         <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
///         argument — a programming error, not data.
///     </para>
///     <para>
///         <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
///         once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
///         <c>0x80730000</c> that four sibling drivers declare under that name is really
///         <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
///         renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
///     </para>
///     <para>
///         <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
///         guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
///         each dataItemId exactly once per call with a single whole-snapshot assignment, so a
///         reader can never observe a torn snapshot (a Good code beside a stale value). It is
///         explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
///         offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
///         interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
///         would serialize the pump against every read for no semantic gain.
///     </para>
///     <para>
///         <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
///         value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
///         deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
///         real content in <c>&lt;Entry key=…&gt;</c> child elements, which reach a text-valued
///         observation concatenated with the keys discarded — a two-entry set reads as the single
///         token <c>"12"</c> — and because the type inference correctly demotes those
///         representations to <see cref="DriverDataType.String"/>, coercion would otherwise
///         "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
///         itself (no value heuristic can separate concatenated entries from a legitimate
///         space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
///         <see cref="MTConnectObservation.IsStructured"/>, which
///         <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
///     </para>
/// </remarks>
internal sealed class MTConnectObservationIndex
{
    /// <summary>
    ///     The one token that means "the Agent has no value for this data item". Matched ordinally:
    ///     the parser already normalizes a CONDITION's <c>&lt;Unavailable/&gt;</c> element name onto
    ///     this exact spelling, so every category lands on one comparison.
    /// </summary>
    private const string UnavailableSentinel = "UNAVAILABLE";

    private const uint Good = 0x00000000u;

    /// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
    private const uint BadNoCommunication = 0x80310000u;

    /// <summary>The tag is authored but the Agent has not yet reported it.</summary>
    private const uint BadWaitingForInitialData = 0x80320000u;

    /// <summary>The dataItemId is neither authored nor ever observed.</summary>
    private const uint BadNodeIdUnknown = 0x80340000u;

    /// <summary>The Agent reported a number the authored type cannot represent.</summary>
    private const uint BadOutOfRange = 0x803C0000u;

    /// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
    private const uint BadNotSupported = 0x803D0000u;

    /// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
    private const uint BadTypeMismatch = 0x80740000u;

    /// <summary>
    ///     MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
    ///     several states reported for one dataItemId <i>within a single document</i> — see
    ///     <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
    ///     fault is information and an absent value is not. Case-insensitive so a vendor's casing
    ///     of a state element still ranks; the parser itself emits the canonical spellings.
    /// </summary>
    private static readonly FrozenDictionary<string, int> ConditionSeverity =
        new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
        {
            [UnavailableSentinel] = 0,
            ["Normal"] = 1,
            ["Warning"] = 2,
            ["Fault"] = 3,
        }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

    private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
    private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
    private readonly TimeProvider _timeProvider;

    /// <summary>
    ///     Builds an index over the driver instance's authored tags.
    /// </summary>
    /// <param name="tags">
    ///     The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
    ///     <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
    ///     legal and means "no authored types": every observation is then indexed as its raw text
    ///     under <see cref="DriverDataType.String"/>.
    ///     <para>
    ///         Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
    ///         operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
    ///         construct would turn an authoring slip into a driver that will not start, whereas
    ///         last-wins is deterministic and costs only that one tag's coercion type.
    ///     </para>
    /// </param>
    /// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
    public MTConnectObservationIndex(
        IEnumerable<MTConnectTagDefinition>? tags = null,
        TimeProvider? timeProvider = null)
    {
        _timeProvider = timeProvider ?? TimeProvider.System;

        var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
        foreach (var tag in tags ?? [])
        {
            if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;

            map[tag.FullName] = tag;
        }

        _tags = map.ToFrozenDictionary(StringComparer.Ordinal);
    }

    /// <summary>The number of distinct dataItemIds currently indexed.</summary>
    public int Count => _snapshots.Count;

    /// <summary>
    ///     Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
    ///     with no authored tag are indexed too — the Agent streams the whole device, so they are
    ///     normal, and dropping them would make a streaming-but-not-yet-authored data item
    ///     indistinguishable from one that does not exist.
    /// </summary>
    /// <param name="result">The parsed streams document. An observation-free result is a legal
    /// keep-alive and leaves the index untouched.</param>
    /// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
    public void Apply(MTConnectStreamsResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        if (result.Observations is not { Count: > 0 }) return;

        // Duplicates are reconciled on the RAW observations first, so each dataItemId is written
        // exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
        // — writing every observation and merging snapshots in place — would need a read-modify-write
        // per key and could expose a half-merged state to a concurrent reader.
        var resolved = new Dictionary<string, MTConnectObservation>(StringComparer.Ordinal);
        foreach (var observation in result.Observations)
        {
            if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;

            resolved[observation.DataItemId] =
                resolved.TryGetValue(observation.DataItemId, out var incumbent)
                    ? Reconcile(incumbent, observation)
                    : observation;
        }

        foreach (var (dataItemId, observation) in resolved)
        {
            _snapshots[dataItemId] = BuildSnapshot(observation);
        }
    }

    /// <summary>
    ///     Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
    ///     unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
    /// </summary>
    /// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
    /// <returns>
    ///     The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
    ///     the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
    ///     state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
    ///     would tell an operator their correctly-authored tag does not exist.
    /// </returns>
    public DataValueSnapshot Get(string dataItemId)
    {
        if (!string.IsNullOrWhiteSpace(dataItemId) &&
            _snapshots.TryGetValue(dataItemId, out var snapshot))
        {
            return snapshot;
        }

        var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
            ? BadWaitingForInitialData
            : BadNodeIdUnknown;

        return new DataValueSnapshot(null, status, null, Now);
    }

    /// <summary>
    ///     Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
    ///     <c>instanceId</c> changes, every value the index holds describes a device model that no
    ///     longer exists, and serving it would be worse than serving nothing.
    /// </summary>
    public void Clear() => _snapshots.Clear();

    private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;

    /// <summary>
    ///     Picks the winner when one dataItemId appears more than once in a single document. A
    ///     <c>&lt;Condition&gt;</c> container legitimately carries several <b>simultaneously active</b>
    ///     states (a Fault and a Warning at once), so for a pair of recognized condition state words
    ///     the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
    ///     Warning purely because of element order. Anything else is last-wins, matching the Agent's
    ///     ascending-sequence ordering.
    ///     <para>
    ///         Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
    ///         statement of the state, so a cleared fault must fall back to Normal. A worst-of that
    ///         spanned Applies would latch every fault forever.
    ///     </para>
    /// </summary>
    private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
    {
        if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
            ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
        {
            return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
        }

        return challenger;
    }

    private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
    {
        var serverTimestamp = Now;
        var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);

        // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
        // the only way to report absence, so an empty element is not an empty string value.
        if (string.IsNullOrWhiteSpace(observation.Value) ||
            string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
        {
            return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
        }

        // DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
        // Value is their concatenated text with the keys discarded (a two-entry set reads "12").
        // Publishing that as a Good string would be noise an operator would trust. Checked before
        // the tag lookup because it is a fact about the wire shape, true whether or not the data
        // item is authored — and after the sentinel, so an unavailable data set still reports the
        // truthful no-comms.
        if (observation.IsStructured)
        {
            return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
        }

        _tags.TryGetValue(observation.DataItemId, out var tag);

        // TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
        // arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
        // type mismatch, and emphatically not the first element parsed out and served as Good.
        // Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
        if (tag is { IsArray: true })
        {
            return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
        }

        // An unauthored observation has no declared target type; String is the only coercion that
        // cannot fail, and it carries the Agent's text with no interpretation applied.
        var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);

        return status == Good
            ? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
            : new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
    }

    /// <summary>
    ///     Converts the Agent's text to the tag's authored type. Every parse is
    ///     <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
    ///     host running a comma-decimal culture would otherwise read <c>123.4567</c> as
    ///     <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
    /// </summary>
    /// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
    private static uint Coerce(string raw, DriverDataType type, out object? value)
    {
        value = null;
        var text = raw.Trim();

        switch (type)
        {
            // Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
            // never a sensible authoring for MTConnect, but degrading it to text is harmless where
            // rejecting it would break a tag for no protection.
            case DriverDataType.String:
            case DriverDataType.Reference:
                value = raw;
                return Good;

            case DriverDataType.Boolean:
                if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
                if (text is "1") { value = true; return Good; }
                if (text is "0") { value = false; return Good; }
                return BadTypeMismatch;

            case DriverDataType.DateTime:
                if (DateTime.TryParse(
                        text,
                        CultureInfo.InvariantCulture,
                        DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
                        out var moment))
                {
                    value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
                    return Good;
                }

                return BadTypeMismatch;

            case DriverDataType.Float32:
                if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
                {
                    value = single;
                    return Good;
                }

                return BadTypeMismatch;

            case DriverDataType.Float64:
                if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
                {
                    value = real;
                    return Good;
                }

                return BadTypeMismatch;

            case DriverDataType.Int16:
                if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
                {
                    value = i16;
                    return Good;
                }

                break;

            case DriverDataType.Int32:
                if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
                {
                    value = i32;
                    return Good;
                }

                break;

            case DriverDataType.Int64:
                if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
                {
                    value = i64;
                    return Good;
                }

                break;

            case DriverDataType.UInt16:
                if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
                {
                    value = u16;
                    return Good;
                }

                break;

            case DriverDataType.UInt32:
                if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
                {
                    value = u32;
                    return Good;
                }

                break;

            case DriverDataType.UInt64:
                if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
                {
                    value = u64;
                    return Good;
                }

                break;

            default:
                // A DriverDataType this driver has no rule for. Text always round-trips, so serving
                // it beats failing a tag over an enum member added later.
                value = raw;
                return Good;
        }

        // Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
        // operator whether to retype the tag or widen it.
        return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
            ? BadOutOfRange   // a number the authored type cannot represent (overflow, or a fraction)
            : BadTypeMismatch; // not a number at all
    }
}
