using System;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;

namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;

public sealed class MxAccessEventMapperTests
{
    private readonly MxAccessEventMapper mapper = new();

    /// <summary>Verifies that creating an OnDataChange event converts value, timestamp, quality, and statuses.</summary>
    [Fact]
    public void CreateOnDataChange_ConvertsValueTimestampQualityAndStatuses()
    {
        DateTime timestamp = new(2026, 4, 26, 12, 30, 0, DateTimeKind.Utc);
        FakeStatus[] statuses =
        {
            new()
            {
                success = -1,
                category = 0,
                detectedBy = 5,
                detail = 0,
            },
        };

        MxEvent mxEvent = mapper.CreateOnDataChange(
            "session-1",
            serverHandle: 12,
            itemHandle: 34,
            value: 42,
            quality: 192,
            timestamp: timestamp,
            statuses: statuses);

        Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family);
        Assert.Equal("session-1", mxEvent.SessionId);
        Assert.Equal(12, mxEvent.ServerHandle);
        Assert.Equal(34, mxEvent.ItemHandle);
        Assert.Equal(42, mxEvent.Value.Int32Value);
        Assert.Equal(192, mxEvent.Quality);
        Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime());
        Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase);

        MxStatusProxy status = Assert.Single(mxEvent.Statuses);
        Assert.Equal(-1, status.Success);
        Assert.Equal(MxStatusCategory.Ok, status.Category);
        Assert.Equal(MxStatusSource.RespondingAutomationObject, status.DetectedBy);
    }

    /// <summary>Verifies that OnWriteComplete and OperationComplete events preserve distinct families.</summary>
    [Fact]
    public void CreateOnWriteCompleteAndOperationComplete_PreservesDistinctFamilies()
    {
        MxEvent writeComplete = mapper.CreateOnWriteComplete(
            "session-1",
            serverHandle: 1,
            itemHandle: 2,
            statuses: Array.Empty<FakeStatus>());
        MxEvent operationComplete = mapper.CreateOperationComplete(
            "session-1",
            serverHandle: 1,
            itemHandle: 2,
            statuses: Array.Empty<FakeStatus>());

        Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family);
        Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, writeComplete.BodyCase);
        Assert.Equal(MxEventFamily.OperationComplete, operationComplete.Family);
        Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, operationComplete.BodyCase);
    }

    /// <summary>Verifies that OnBufferedDataChange events preserve raw data type and array metadata.</summary>
    [Fact]
    public void CreateOnBufferedDataChange_PreservesRawDataTypeAndArrayMetadata()
    {
        DateTime firstTimestamp = new(2026, 4, 26, 13, 0, 0, DateTimeKind.Utc);
        DateTime secondTimestamp = new(2026, 4, 26, 13, 1, 0, DateTimeKind.Utc);

        MxEvent mxEvent = mapper.CreateOnBufferedDataChange(
            "session-1",
            serverHandle: 10,
            itemHandle: 20,
            rawDataType: 2,
            value: new[] { 7, 8 },
            quality: new[] { 192, 0 },
            timestamp: new[] { firstTimestamp, secondTimestamp },
            statuses: null);

        Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family);
        Assert.Equal(MxDataType.Integer, mxEvent.OnBufferedDataChange.DataType);
        Assert.Equal(2, mxEvent.OnBufferedDataChange.RawDataType);
        Assert.Equal(MxDataType.Integer, mxEvent.Value.ArrayValue.ElementDataType);
        Assert.Equal(new[] { 7, 8 }, mxEvent.Value.ArrayValue.Int32Values.Values);
        Assert.Equal(new[] { 192, 0 }, mxEvent.OnBufferedDataChange.QualityValues.Int32Values.Values);
        Assert.Equal(2, mxEvent.OnBufferedDataChange.TimestampValues.TimestampValues.Values.Count);
    }

    /// <summary>Verifies that MapMxDataType maps raw MXAccess data types to protobuf enum values.</summary>
    /// <param name="rawDataType">Raw MXAccess data type value to map.</param>
    /// <param name="expectedDataType">Expected MxDataType enum value.</param>
    [Theory]
    [InlineData(-1, MxDataType.Unknown)]
    [InlineData(0, MxDataType.NoData)]
    [InlineData(1, MxDataType.Boolean)]
    [InlineData(2, MxDataType.Integer)]
    [InlineData(6, MxDataType.Time)]
    [InlineData(15, MxDataType.InternationalizedString)]
    [InlineData(999, MxDataType.Unknown)]
    public void MapMxDataType_MapsInstalledMxAccessValues(
        int rawDataType,
        MxDataType expectedDataType)
    {
        Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType));
    }

    /// <summary>Verifies CreateOnAlarmTransition packs the full alarm payload.</summary>
    [Fact]
    public void CreateOnAlarmTransition_PopulatesFullPayload()
    {
        DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
        DateTime ack = raise.AddSeconds(45);

        MxEvent mxEvent = mapper.CreateOnAlarmTransition(
            sessionId: "session-1",
            alarmFullReference: "Tank01.Level.HiHi",
            sourceObjectReference: "Tank01",
            alarmTypeName: "AnalogLimitAlarm.HiHi",
            transitionKind: AlarmTransitionKind.Acknowledge,
            severity: 750,
            originalRaiseTimestampUtc: raise,
            transitionTimestampUtc: ack,
            operatorUser: "alice",
            operatorComment: "investigating",
            category: "Process",
            description: "Tank 01 high-high level",
            statuses: null);

        Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family);
        Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, mxEvent.BodyCase);

        OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition;
        Assert.Equal("Tank01.Level.HiHi", body.AlarmFullReference);
        Assert.Equal("Tank01", body.SourceObjectReference);
        Assert.Equal("AnalogLimitAlarm.HiHi", body.AlarmTypeName);
        Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
        Assert.Equal(750, body.Severity);
        Assert.Equal(raise, body.OriginalRaiseTimestamp.ToDateTime());
        Assert.Equal(ack, body.TransitionTimestamp.ToDateTime());
        Assert.Equal("alice", body.OperatorUser);
        Assert.Equal("investigating", body.OperatorComment);
        Assert.Equal("Process", body.Category);
        Assert.Equal("Tank 01 high-high level", body.Description);
    }

    /// <summary>Verifies CreateOnAlarmTransition handles a Raise transition with no operator metadata.</summary>
    [Fact]
    public void CreateOnAlarmTransition_RaiseTransitionLeavesOperatorFieldsEmpty()
    {
        DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);

        MxEvent mxEvent = mapper.CreateOnAlarmTransition(
            sessionId: "session-1",
            alarmFullReference: "Tank01.Level.HiHi",
            sourceObjectReference: "Tank01",
            alarmTypeName: "AnalogLimitAlarm.HiHi",
            transitionKind: AlarmTransitionKind.Raise,
            severity: 750,
            originalRaiseTimestampUtc: null,
            transitionTimestampUtc: raise,
            operatorUser: string.Empty,
            operatorComment: string.Empty,
            category: "Process",
            description: "Tank 01 high-high level",
            statuses: null);

        Assert.Equal(AlarmTransitionKind.Raise, mxEvent.OnAlarmTransition.TransitionKind);
        Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorUser);
        Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorComment);
        Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp);
    }

    /// <summary>
    ///     Verifies that an OnDataChange whose timestamp arrives as the
    ///     VT_BSTR string MXAccess actually delivers still populates
    ///     <see cref="MxEvent.SourceTimestamp"/> — the string is parsed as
    ///     local time and converted to UTC.
    /// </summary>
    [Fact]
    public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp()
    {
        // The exact shape MXAccess fires (see captures/003-subscribe-scalars).
        const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM";

        MxEvent mxEvent = mapper.CreateOnDataChange(
            "session-1",
            serverHandle: 1,
            itemHandle: 1,
            value: 99,
            quality: 192,
            timestamp: mxAccessTimestamp,
            statuses: null);

        Assert.NotNull(mxEvent.SourceTimestamp);

        DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
        DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
        Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime());
    }

    /// <summary>
    ///     Verifies the MXAccess timestamp string is interpreted as the host's
    ///     local time and returned as UTC. Written timezone-independently by
    ///     round-tripping a local wall-clock time.
    /// </summary>
    [Fact]
    public void TryParseSourceTimestamp_InterpretsStringAsLocalTime()
    {
        DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified);
        string text = localWall.ToString(CultureInfo.CurrentCulture);

        Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
        Assert.Equal(DateTimeKind.Utc, utc.Kind);

        DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
        Assert.Equal(expectedUtc, utc);
    }

    /// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary>
    /// <param name="text">Unparseable or empty timestamp string.</param>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("not a timestamp")]
    public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text)
    {
        Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
    }

    /// <summary>
    ///     Verifies the exact-format fast path PERF-20 added in front of the
    ///     general parse chain returns exactly what that chain returned. The
    ///     cases cover the captured MXAccess shape (which the fast path is meant
    ///     to catch on a US-shaped host), its zero-padded and second-precision
    ///     siblings, and strings the derived formats cannot match — an ISO
    ///     round-trip form, a long-date form, and a day-first European form —
    ///     which must fall through to the unchanged two-stage chain. The
    ///     expectation is computed with the pre-PERF-20 chain in this test, so it
    ///     holds on any host culture: the fast path is a shortcut, never a
    ///     different answer.
    /// </summary>
    /// <param name="text">Timestamp string to parse.</param>
    [Theory]
    [InlineData("3/26/2026 1:38:22.907 PM")]
    [InlineData("3/26/2026 1:38:22 PM")]
    [InlineData("03/26/2026 01:38:22 PM")]
    [InlineData("12/31/2026 11:59:59.999 PM")]
    [InlineData("2026-03-26T13:38:22.9070000")]
    [InlineData("Thursday, March 26, 2026 1:38:22 PM")]
    [InlineData("26.03.2026 13:38:22")]
    [InlineData("not a timestamp")]
    public void TryParseSourceTimestamp_MatchesPreFastPathChain(string text)
    {
        bool expectedParsed = TryParseWithGeneralChainOnly(text, out DateTime expectedUtc);

        bool parsed = MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc);

        Assert.Equal(expectedParsed, parsed);
        Assert.Equal(expectedUtc, utc);
        if (parsed)
        {
            Assert.Equal(DateTimeKind.Utc, utc.Kind);
        }
    }

    /// <summary>
    ///     Verifies a timestamp written in the host culture's own patterns —
    ///     including the millisecond fraction MXAccess appends, which no culture
    ///     publishes in its long-time pattern — parses as local wall-clock time
    ///     and comes back as UTC. This is the string shape the derived exact
    ///     formats are built for, so it exercises the fast path on the worker's
    ///     own host regardless of which culture that host runs.
    /// </summary>
    [Fact]
    public void TryParseSourceTimestamp_WithCultureShapedMillisecondTimestamp_ReturnsLocalWallClockAsUtc()
    {
        CultureInfo culture = CultureInfo.CurrentCulture;
        string longTime = culture.DateTimeFormat.LongTimePattern;
        int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);

        // Every Windows culture publishes seconds in its long-time pattern; the
        // guard keeps the test honest rather than green-by-accident if one does not.
        Assert.True(secondsIndex >= 0, $"Long-time pattern '{longTime}' has no seconds specifier.");

        DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
        string text = localWall.ToString(
            culture.DateTimeFormat.ShortDatePattern + " " + longTime.Insert(secondsIndex + 2, ".fff"),
            culture);

        Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
        Assert.Equal(DateTimeKind.Utc, utc.Kind);
        Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
    }

    /// <summary>
    ///     Verifies format derivation survives a culture whose long-time pattern
    ///     hides an "ss" inside quoted literal text — the case where blindly
    ///     inserting ".fff" at the first "ss" produces a format that mangles the
    ///     value or is outright malformed, and a malformed format makes exact
    ///     parsing throw rather than report failure. Derivation must reject such
    ///     a candidate at build time, so parsing here stays exception-free and
    ///     the culture's own published shape still reads as local wall-clock
    ///     time. Uses a culture no other test touches, because the format cache
    ///     compares cultures by name.
    /// </summary>
    [Fact]
    public void TryParseSourceTimestamp_WithLiteralSecondsInCulturePattern_ParsesWithoutThrowing()
    {
        CultureInfo pathological = new("en-ZA")
        {
            DateTimeFormat =
            {
                ShortDatePattern = "M/d/yyyy",
                LongTimePattern = "'sss' HH:mm:ss",
            },
        };

        CultureInfo original = CultureInfo.CurrentCulture;
        try
        {
            CultureInfo.CurrentCulture = pathological;
            DateTime localWall = new(2026, 3, 26, 13, 38, 22, DateTimeKind.Unspecified);

            // The culture's published shape: "3/26/2026 sss 13:38:22".
            string published = localWall.ToString(
                pathological.DateTimeFormat.ShortDatePattern + " " + pathological.DateTimeFormat.LongTimePattern,
                pathological);

            Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(published, out DateTime utc));
            Assert.Equal(DateTimeKind.Utc, utc.Kind);
            Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);

            // Shapes this culture cannot describe must still fail quietly rather
            // than throw out of the derived formats.
            Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("not a timestamp", out _));
            Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("sss sss sss", out _));
        }
        finally
        {
            CultureInfo.CurrentCulture = original;
        }
    }

    /// <summary>
    ///     The parse chain exactly as it stood before PERF-20 added the
    ///     exact-format stage, used as the parity oracle above.
    /// </summary>
    /// <param name="text">Timestamp string to parse.</param>
    /// <param name="utc">The parsed UTC timestamp on success.</param>
    /// <returns><see langword="true"/> when the string parsed successfully.</returns>
    private static bool TryParseWithGeneralChainOnly(string? text, out DateTime utc)
    {
        utc = default;
        if (string.IsNullOrWhiteSpace(text))
        {
            return false;
        }

        const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
        if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
            || DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
        {
            utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
            return true;
        }

        return false;
    }

    private sealed class FakeStatus
    {
        public int success;
        public int category;
        public int detectedBy;
        public int detail;
    }
}
