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

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

/// <summary>
///     The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
///     <see cref="DriverDataType" /> plus the array shape that
///     <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
///     expect. Returned by value (a readonly record struct) so the discovery loop and the
///     AdminUI editor can call the inference on a hot path without allocating.
/// </summary>
/// <param name="DataType">The scalar element type of the observation.</param>
/// <param name="IsArray">
///     <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
///     observation carries a vector of samples rather than a single value.
/// </param>
/// <param name="ArrayDim">
///     The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
///     model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
///     (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
/// </param>
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);

/// <summary>
///     Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
///     representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
///     no I/O, no logging, no mutable state.
/// </summary>
/// <remarks>
///     <para>
///         This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
///         tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
///         (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
///         universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
///         AdminUI typed tag editor (shows the inferred type and offers an override).
///     </para>
///     <para>
///         <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
///         defines hundreds of <c>type</c> values with more added each revision. This is therefore a
///         <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
///         The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
///         fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
///         always round-trips and the author can retype it. So every default leans to
///         <c>String</c> except where the standard itself guarantees a number.
///     </para>
///     <para>
///         <b>Defaulting rule, per category</b> (design §3.3):
///     </para>
///     <list type="bullet">
///         <item>
///             <description>
///                 <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
///                 unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
///                 category as a continuously-varying measured quantity, so the category alone is
///                 the guarantee — a missing <c>units</c> attribute is a gap in the device model,
///                 not evidence that the observation is text. <c>Float64</c> rather than an integer
///                 type because a double parse accepts both <c>3</c> and <c>3.5</c>.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
///                 exception list (<see cref="NumericEventTypes" />) of standard types that are
///                 defined as integers. The overwhelming majority of EVENT types are controlled
///                 vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
///                 text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
///                 deliberately short — each entry is a coercion risk, while every omission is
///                 merely a string the author can retype.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
///                 is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
///                 <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
///             </description>
///         </item>
///         <item>
///             <description>
///                 An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
///                 Probe XML in the wild is inconsistent; with no category we have no evidence the
///                 observation is numeric, so we pick the type that cannot fail to parse.
///             </description>
///         </item>
///     </list>
///     <para>
///         Representation overrides the category where the two disagree about shape:
///         <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
///         <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
///         defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
///         <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
///     </para>
///     <para>
///         <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
///         <c>_</c>, <c>-</c> and whitespace are ignored wherever they appear, so
///         <c>PART_COUNT</c> ≡ <c>PartCount</c> ≡ <c>part-count</c> ≡ <c>partcount</c>. This is not
///         mere leniency: <b>MTConnect spells the same concept two different ways in two different
///         documents</b>. The Devices document (<c>/probe</c>) writes <c>DataItem@type</c> in
///         UPPER_SNAKE (<c>PART_COUNT</c>, <c>POSITION</c>, <c>PATH_FEEDRATE</c>), while the Streams
///         documents (<c>/current</c>, <c>/sample</c>) name the observation element in PascalCase
///         (<c>PartCount</c>, <c>Position</c>). Discovery reads the <i>probe</i> spelling, and plain
///         case-insensitivity does not bridge the underscore — so matching only the PascalCase
///         spelling types every real agent's <c>PART_COUNT</c> as <see cref="DriverDataType.String" />
///         while every unit test using the sketch's PascalCase spelling stays green.
///     </para>
/// </remarks>
public static class MTConnectDataTypeInference
{
    private const string CategorySample = "SAMPLE";
    private const string CategoryEvent = "EVENT";

    private const string RepresentationTimeSeries = "TIME_SERIES";
    private const string RepresentationDataSet = "DATA_SET";
    private const string RepresentationTable = "TABLE";

    /// <summary>
    ///     The <c>EVENT</c> types the standard defines as integers — the exception list to the
    ///     "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
    ///     every agent reports it as a parseable integer, and being wrong costs Bad quality.
    ///     <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
    ///     both are mapped so a current-version agent is not silently mistyped.
    ///     <para>
    ///         The entries are written in the Streams (PascalCase) spelling, but the set's comparer
    ///         is <see cref="SeparatorInsensitiveComparer" />, so the probe document's
    ///         <c>PART_COUNT</c> / <c>LINE_NUMBER</c> hit the same entries. Adding a spelling variant
    ///         here would be redundant, not additional coverage.
    ///     </para>
    /// </summary>
    private static readonly FrozenSet<string> NumericEventTypes =
        new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);

    /// <summary>
    ///     Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
    ///     metadata. See the type-level remarks for the full rule and its rationale.
    /// </summary>
    /// <param name="category">
    ///     The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
    ///     Case-insensitive; an unknown, blank, or <c>null</c> value yields
    ///     <see cref="DriverDataType.String" />.
    /// </param>
    /// <param name="type">
    ///     The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
    ///     <c>PART_COUNT</c> / <c>POSITION</c> or the streams' <c>PartCount</c> / <c>Position</c>.
    ///     Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
    ///     or <c>null</c> value falls back to the category default.
    /// </param>
    /// <param name="units">
    ///     The declared engineering units, if any. Accepted for signature stability and because every
    ///     call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
    ///     definition of the category, so units add no discriminating power there, and treating a
    ///     units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
    /// </param>
    /// <param name="representation">
    ///     The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
    ///     <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
    /// </param>
    /// <param name="sampleCount">
    ///     The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
    ///     <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
    ///     value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
    /// </param>
    /// <returns>The inferred scalar type plus array shape.</returns>
    public static MTConnectInferredType Infer(
        string? category,
        string? type = null,
        string? units = null,
        string? representation = null,
        int? sampleCount = null)
    {
        _ = units; // See the parameter doc: intentionally not load-bearing in v1.

        var trimmedCategory = category.AsSpan().Trim();
        var trimmedRepresentation = representation.AsSpan().Trim();

        // A key-value observation is never a number, whatever the category claims. Checked first so
        // a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
        if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
            return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);

        if (Matches(trimmedCategory, CategorySample))
        {
            // TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
            if (Matches(trimmedRepresentation, RepresentationTimeSeries))
                return new MTConnectInferredType(
                    DriverDataType.Float64,
                    IsArray: true,
                    ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);

            return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
        }

        if (Matches(trimmedCategory, CategoryEvent))
        {
            // No Trim() here: the set's comparer already ignores whitespace and separators
            // wherever they appear, so the raw attribute value is looked up allocation-free.
            var dataType = type is { Length: > 0 } && NumericEventTypes.Contains(type)
                ? DriverDataType.Int64
                : DriverDataType.String;

            return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
        }

        // CONDITION (a state word) and every unknown / blank / missing category.
        return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
    }

    private static bool Matches(ReadOnlySpan<char> value, string expected)
        => value.Equals(expected, StringComparison.OrdinalIgnoreCase);

    /// <summary>
    ///     Ordinal-ignore-case equality that additionally skips <c>_</c>, <c>-</c> and whitespace
    ///     wherever they occur, so the probe document's <c>PART_COUNT</c> and the streams
    ///     document's <c>PartCount</c> are one key. Backs <see cref="NumericEventTypes" />.
    /// </summary>
    /// <remarks>
    ///     Comparing through a comparer rather than normalising the input keeps the lookup
    ///     allocation-free on the discovery hot path: no <c>string.Replace</c>, no
    ///     <c>Trim()</c>, no temporary buffer — the raw attribute value from the parser is passed
    ///     straight to <see cref="FrozenSet{T}.Contains" />.
    /// </remarks>
    private sealed class SeparatorInsensitiveComparer : IEqualityComparer<string>
    {
        /// <summary>The single shared instance; the comparer is stateless.</summary>
        internal static readonly SeparatorInsensitiveComparer Instance = new();

        private SeparatorInsensitiveComparer() { }

        /// <inheritdoc />
        public bool Equals(string? x, string? y)
        {
            if (ReferenceEquals(x, y)) return true;
            if (x is null || y is null) return false;

            int i = 0, j = 0;
            while (true)
            {
                while (i < x.Length && IsSeparator(x[i])) i++;
                while (j < y.Length && IsSeparator(y[j])) j++;

                if (i == x.Length || j == y.Length) return i == x.Length && j == y.Length;
                if (char.ToUpperInvariant(x[i]) != char.ToUpperInvariant(y[j])) return false;

                i++;
                j++;
            }
        }

        /// <inheritdoc />
        public int GetHashCode(string obj)
        {
            // Must hash exactly the characters Equals compares, or two equal spellings land in
            // different buckets and the set silently misses.
            var hash = new HashCode();
            foreach (var c in obj)
            {
                if (IsSeparator(c)) continue;
                hash.Add(char.ToUpperInvariant(c));
            }

            return hash.ToHashCode();
        }

        private static bool IsSeparator(char c) => c is '_' or '-' || char.IsWhiteSpace(c);
    }
}
