using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Golden table for <see cref="MTConnectDataTypeInference.Infer" /> (design §3.3).
///     Three call sites must agree byte-for-byte on this mapping — <c>ITagDiscovery.DiscoverAsync</c>,
///     the universal browser's commit path, and the AdminUI typed tag editor — so the table is
///     pinned here rather than re-derived at each seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDataTypeInferenceTests
{
    // ---------------------------------------------------------------------
    // The design §3.3 golden table.
    // ---------------------------------------------------------------------

    [Theory]
    // SAMPLE numeric with units → Float64.
    [InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
    [InlineData("SAMPLE", "SpindleSpeed", "REVOLUTION/MINUTE", "VALUE", DriverDataType.Float64)]
    // EVENT numeric type → Int64.
    [InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
    [InlineData("EVENT", "Line", null, null, DriverDataType.Int64)]
    // EVENT controlled vocabulary → String.
    [InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
    [InlineData("EVENT", "ControllerMode", null, null, DriverDataType.String)]
    [InlineData("EVENT", "Availability", null, null, DriverDataType.String)]
    // EVENT free text → String.
    [InlineData("EVENT", "Program", null, null, DriverDataType.String)]
    [InlineData("EVENT", "Block", null, null, DriverDataType.String)]
    [InlineData("EVENT", "Message", null, null, DriverDataType.String)]
    // CONDITION → String (the state word).
    [InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
    [InlineData("CONDITION", "SystemCondition", null, null, DriverDataType.String)]
    public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
        => MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);

    // ---------------------------------------------------------------------
    // The SAME golden table, spelled the way a real agent's /probe document
    // spells it. MTConnect writes the same concept two ways: the Devices
    // (/probe) document's `DataItem@type` is UPPER_SNAKE (PART_COUNT), while
    // the Streams (/current, /sample) document's observation element name is
    // PascalCase (PartCount). `DiscoverAsync` feeds the *probe* spelling, so
    // an inference that only knows the PascalCase spelling is green in tests
    // and wrong live. Every row below is a DataItem from Fixtures/probe.xml.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData("EVENT", "PART_COUNT", null, DriverDataType.Int64)]            // dev1_partcount
    [InlineData("SAMPLE", "POSITION", "MILLIMETER", DriverDataType.Float64)]   // dev1_pos
    [InlineData("EVENT", "EXECUTION", null, DriverDataType.String)]            // dev1_execution
    [InlineData("EVENT", "PROGRAM", null, DriverDataType.String)]              // dev1_program
    [InlineData("EVENT", "AVAILABILITY", null, DriverDataType.String)]         // dev1_avail
    [InlineData("CONDITION", "SYSTEM", null, DriverDataType.String)]           // dev1_system_cond
    public void Infer_maps_the_probe_documents_UPPER_SNAKE_spelling(
        string cat, string type, string? units, DriverDataType expected)
        => MTConnectDataTypeInference.Infer(cat, type, units, null).DataType.ShouldBe(expected);

    [Fact]
    public void The_probes_TIME_SERIES_item_is_a_float64_array_of_its_declared_sample_count()
    {
        // dev1_vibration_ts: category=SAMPLE type=PATH_FEEDRATE representation=TIME_SERIES sampleCount=10.
        var r = MTConnectDataTypeInference.Infer(
            "SAMPLE", "PATH_FEEDRATE", "MILLIMETER/SECOND", "TIME_SERIES", sampleCount: 10);

        r.DataType.ShouldBe(DriverDataType.Float64);
        r.IsArray.ShouldBeTrue();
        r.ArrayDim.ShouldBe(10u);
    }

    [Theory]
    [InlineData("PART_COUNT", "PartCount")]
    [InlineData("LINE_NUMBER", "LineNumber")]
    [InlineData("part_count", "partcount")]
    [InlineData("PART-COUNT", "PartCount")]
    [InlineData("PART_COUNT", " Part Count ")]
    public void The_two_document_spellings_of_one_type_infer_identically(string probeSpelling, string streamsSpelling)
    {
        // The equivalence is the whole point: a tag discovered from /probe and the same tag
        // named from a /current observation must land on one type, or the authored config
        // stops matching the value that arrives.
        var fromProbe = MTConnectDataTypeInference.Infer("EVENT", probeSpelling, null, null);
        var fromStreams = MTConnectDataTypeInference.Infer("EVENT", streamsSpelling, null, null);

        fromProbe.ShouldBe(fromStreams);
        fromProbe.DataType.ShouldBe(DriverDataType.Int64);
    }

    [Theory]
    [InlineData("PART_COUNTER")]
    [InlineData("PARTS_COUNT")]
    [InlineData("LINE_NUMBERS")]
    [InlineData("_")]
    public void Separator_insensitivity_does_not_widen_the_numeric_EVENT_exception_list(string type)
        // Falsifiability control: ignoring separators must not turn a *different* type into a
        // match. The exception list stays exactly PartCount / Line / LineNumber.
        => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);

    [Fact]
    public void TimeSeries_sample_is_a_float64_array_with_declared_count()
    {
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);

        r.DataType.ShouldBe(DriverDataType.Float64);
        r.IsArray.ShouldBeTrue();
        r.ArrayDim.ShouldBe(8u);
    }

    // ---------------------------------------------------------------------
    // Array shape: only a SAMPLE/TIME_SERIES is an array, and ArrayDim is
    // only populated when the probe declared a usable sampleCount.
    // ---------------------------------------------------------------------

    [Fact]
    public void TimeSeries_without_a_declared_sample_count_is_a_variable_length_array()
    {
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES");

        r.IsArray.ShouldBeTrue();
        r.ArrayDim.ShouldBeNull();
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void TimeSeries_with_a_non_positive_sample_count_reports_no_dimension(int declared)
    {
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", declared);

        r.IsArray.ShouldBeTrue();
        r.ArrayDim.ShouldBeNull();
    }

    [Theory]
    [InlineData("SAMPLE", "Position", "VALUE")]
    [InlineData("SAMPLE", "Position", null)]
    [InlineData("EVENT", "PartCount", "VALUE")]
    [InlineData("CONDITION", "Temperature", null)]
    public void A_scalar_item_is_never_an_array(string cat, string type, string? rep)
    {
        var r = MTConnectDataTypeInference.Infer(cat, type, null, rep);

        r.IsArray.ShouldBeFalse();
        r.ArrayDim.ShouldBeNull();
    }

    [Fact]
    public void A_declared_sample_count_is_ignored_when_the_item_is_not_a_time_series()
    {
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "VALUE", sampleCount: 8);

        r.IsArray.ShouldBeFalse();
        r.ArrayDim.ShouldBeNull();
    }

    [Theory]
    [InlineData("EVENT")]
    [InlineData("CONDITION")]
    public void TIME_SERIES_outside_the_SAMPLE_category_is_not_treated_as_an_array(string cat)
    {
        // TIME_SERIES is defined only for SAMPLE. Honouring it elsewhere would hand the
        // address space an array node whose observations are scalars.
        var r = MTConnectDataTypeInference.Infer(cat, "Anything", null, "TIME_SERIES", sampleCount: 8);

        r.IsArray.ShouldBeFalse();
        r.ArrayDim.ShouldBeNull();
        r.DataType.ShouldBe(DriverDataType.String);
    }

    // ---------------------------------------------------------------------
    // The defaulting rule for unrecognised `type` values — the part of the
    // table that is a *rule*, not an enumeration. MTConnect defines hundreds
    // of types; the fallback per category is what actually ships.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData("Xacceleration")]
    [InlineData("SomeVendorExtension")]
    [InlineData(null)]
    [InlineData("")]
    public void An_unrecognised_SAMPLE_type_still_defaults_to_Float64(string? type)
        => MTConnectDataTypeInference.Infer("SAMPLE", type, null, null).DataType.ShouldBe(DriverDataType.Float64);

    [Fact]
    public void A_SAMPLE_without_units_is_still_numeric()
        // SAMPLE is numeric by definition in the standard; a missing `units` attribute is a
        // gap in the device model, not evidence that the observation is text.
        => MTConnectDataTypeInference.Infer("SAMPLE", "Position", null, null).DataType
            .ShouldBe(DriverDataType.Float64);

    [Theory]
    [InlineData("ToolNumber")]
    [InlineData("PalletId")]
    [InlineData("LineLabel")]
    [InlineData("SomeVendorExtension")]
    [InlineData(null)]
    [InlineData("")]
    public void An_unrecognised_EVENT_type_defaults_to_String(string? type)
        // Fail-safe direction: a wrong numeric coercion produces a Bad-quality value at runtime,
        // whereas String always round-trips and the author can override.
        => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);

    [Theory]
    [InlineData("LineNumber")]
    public void The_modern_spelling_of_a_known_numeric_EVENT_is_also_numeric(string type)
        // `Line` was superseded by `LineNumber`; mapping only the deprecated spelling would
        // silently mistype the same data item on any current-version agent.
        => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);

    [Theory]
    [InlineData("Anything")]
    [InlineData(null)]
    public void Every_CONDITION_is_a_String_regardless_of_type(string? type)
        => MTConnectDataTypeInference.Infer("CONDITION", type, "CELSIUS", null).DataType
            .ShouldBe(DriverDataType.String);

    // ---------------------------------------------------------------------
    // Unknown / missing category — probe XML in the wild is inconsistent.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("ASSET")]
    [InlineData("nonsense")]
    public void An_unknown_or_missing_category_falls_back_to_String(string? cat)
        // We cannot know the observation is numeric, so we choose the type that always
        // round-trips rather than one that can fail to parse.
        => MTConnectDataTypeInference.Infer(cat, "Position", "MILLIMETER", null).DataType
            .ShouldBe(DriverDataType.String);

    // ---------------------------------------------------------------------
    // Case / whitespace tolerance.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData("sample", DriverDataType.Float64)]
    [InlineData("Sample", DriverDataType.Float64)]
    [InlineData("  SAMPLE  ", DriverDataType.Float64)]
    [InlineData("condition", DriverDataType.String)]
    public void Category_matching_is_case_and_whitespace_insensitive(string cat, DriverDataType expected)
        => MTConnectDataTypeInference.Infer(cat, "Position", "MM", null).DataType.ShouldBe(expected);

    [Theory]
    [InlineData("partcount")]
    [InlineData("PARTCOUNT")]
    [InlineData(" PartCount ")]
    public void Numeric_EVENT_type_matching_is_case_and_whitespace_insensitive(string type)
        => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);

    [Theory]
    [InlineData("time_series")]
    [InlineData("Time_Series")]
    [InlineData(" TIME_SERIES ")]
    public void Representation_matching_is_case_and_whitespace_insensitive(string rep)
        => MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", rep, 4).IsArray.ShouldBeTrue();

    // ---------------------------------------------------------------------
    // Non-scalar representations. A DATA_SET / TABLE observation is a
    // key-value map, not a number — typing it Float64 guarantees Bad quality.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData("DATA_SET")]
    [InlineData("TABLE")]
    [InlineData("data_set")]
    public void A_key_value_representation_is_a_String_even_on_a_SAMPLE(string rep)
    {
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", rep);

        r.DataType.ShouldBe(DriverDataType.String);
        r.IsArray.ShouldBeFalse();
    }

    [Fact]
    public void A_key_value_representation_also_demotes_a_numeric_EVENT()
    {
        var r = MTConnectDataTypeInference.Infer("EVENT", "PartCount", null, "DATA_SET");

        r.DataType.ShouldBe(DriverDataType.String);
        r.IsArray.ShouldBeFalse();
    }

    [Fact]
    public void DISCRETE_is_a_scalar_representation_and_does_not_change_the_type()
    {
        // DISCRETE changes *when* the agent reports, not what the value is.
        var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", "DISCRETE");

        r.DataType.ShouldBe(DriverDataType.Float64);
        r.IsArray.ShouldBeFalse();
    }

    // ---------------------------------------------------------------------
    // Purity — the three call sites rely on identical results for identical
    // inputs with no shared state between calls.
    // ---------------------------------------------------------------------

    [Fact]
    public void Infer_is_deterministic()
    {
        var first = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
        var second = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);

        second.ShouldBe(first);
    }
}
