using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;

namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;

/// <summary>
///     Verifies <see cref="ScriptLogTopicSink"/> converts Serilog <see cref="LogEvent"/>
///     instances into <see cref="ScriptLogEntry"/> records and hands them to the
///     <see cref="IScriptLogPublisher"/>. Covers level filtering, property extraction,
///     fallback for missing identity props, and message template rendering.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogTopicSinkTests
{
    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------

    private sealed class FakePublisher : IScriptLogPublisher
    {
        /// <summary>Gets the entries that have been published.</summary>
        public List<ScriptLogEntry> Published { get; } = [];

        /// <inheritdoc/>
        public void Publish(ScriptLogEntry entry) => Published.Add(entry);
    }

    /// <summary>Builds a <see cref="LogEvent"/> with an optional set of string properties.</summary>
    private static LogEvent Evt(
        LogEventLevel level,
        string template,
        params (string Key, string Val)[] props)
    {
        var parser = new Serilog.Parsing.MessageTemplateParser();
        var tmpl = parser.Parse(template);
        var logProps = props.Select(p => new LogEventProperty(p.Key, new ScalarValue(p.Val)));
        return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, tmpl, logProps);
    }

    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------

    /// <summary>
    ///     An Information event with all identity props present → publisher receives
    ///     exactly one entry with correctly mapped fields and null AlarmId.
    /// </summary>
    [Fact]
    public void Information_event_with_all_identity_props_is_published_correctly()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher);
        var evt = Evt(LogEventLevel.Information, "hello",
            (ScriptLoggerFactory.ScriptIdProperty, "S1"),
            (ScriptLoggerFactory.VirtualTagIdProperty, "V1"),
            (ScriptLoggerFactory.EquipmentIdProperty, "EQ1"));

        sink.Emit(evt);

        publisher.Published.Count.ShouldBe(1);
        var entry = publisher.Published[0];
        entry.ScriptId.ShouldBe("S1");
        entry.Level.ShouldBe("Information");
        entry.Message.ShouldBe("hello");
        entry.VirtualTagId.ShouldBe("V1");
        entry.EquipmentId.ShouldBe("EQ1");
        entry.AlarmId.ShouldBeNull();
    }

    /// <summary>
    ///     An AlarmId property on the event is mapped to <see cref="ScriptLogEntry.AlarmId"/>.
    /// </summary>
    [Fact]
    public void AlarmId_prop_is_mapped_to_entry_AlarmId()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher);
        var evt = Evt(LogEventLevel.Information, "alarm fired",
            (ScriptLoggerFactory.ScriptIdProperty, "S2"),
            (ScriptLoggerFactory.AlarmIdProperty, "A1"));

        sink.Emit(evt);

        publisher.Published[0].AlarmId.ShouldBe("A1");
    }

    /// <summary>
    ///     When no identity properties are present all nullable fields are null and
    ///     ScriptId falls back to <c>"unknown"</c>.
    /// </summary>
    [Fact]
    public void Absent_identity_props_yield_null_fields_and_unknown_ScriptId()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher);
        var evt = Evt(LogEventLevel.Warning, "bare message");

        sink.Emit(evt);

        publisher.Published.Count.ShouldBe(1);
        var entry = publisher.Published[0];
        entry.ScriptId.ShouldBe("unknown");
        entry.VirtualTagId.ShouldBeNull();
        entry.AlarmId.ShouldBeNull();
        entry.EquipmentId.ShouldBeNull();
    }

    /// <summary>
    ///     When only a <c>ScriptName</c> property (no <c>ScriptId</c>) is present,
    ///     <see cref="ScriptLogEntry.ScriptId"/> falls back to the ScriptName value.
    /// </summary>
    [Fact]
    public void ScriptName_fallback_used_when_ScriptId_absent()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher);
        var evt = Evt(LogEventLevel.Information, "using name fallback",
            (ScriptLoggerFactory.ScriptNameProperty, "NameFallback"));

        sink.Emit(evt);

        publisher.Published[0].ScriptId.ShouldBe("NameFallback");
    }

    /// <summary>
    ///     A Debug event is filtered by the default minimum level (Information) and
    ///     the publisher receives nothing.
    /// </summary>
    [Fact]
    public void Debug_event_filtered_by_default_minimum_level()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher); // default min = Information
        var evt = Evt(LogEventLevel.Debug, "debug noise",
            (ScriptLoggerFactory.ScriptIdProperty, "S3"));

        sink.Emit(evt);

        publisher.Published.ShouldBeEmpty();
    }

    /// <summary>
    ///     When the sink is constructed with <c>min: Debug</c>, a Debug event reaches
    ///     the publisher with <c>Level=="Debug"</c>.
    /// </summary>
    [Fact]
    public void Debug_event_published_when_sink_min_level_is_Debug()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher, min: LogEventLevel.Debug);
        var evt = Evt(LogEventLevel.Debug, "debug detail",
            (ScriptLoggerFactory.ScriptIdProperty, "S4"));

        sink.Emit(evt);

        publisher.Published.Count.ShouldBe(1);
        publisher.Published[0].Level.ShouldBe("Debug");
    }

    /// <summary>
    ///     A templated message is fully rendered before being stored so the consumer
    ///     sees a plain string rather than a raw template.
    /// </summary>
    [Fact]
    public void Templated_message_is_rendered_before_publishing()
    {
        var publisher = new FakePublisher();
        var sink = new ScriptLogTopicSink(publisher);

        // Build the event manually to include a numeric property (can't be passed as string
        // in the helper since we need an integer ScalarValue for realistic rendering).
        var parser = new Serilog.Parsing.MessageTemplateParser();
        var tmpl = parser.Parse("v={V}");
        var props = new[] { new LogEventProperty("V", new ScalarValue(3)) };
        var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, tmpl, props);

        sink.Emit(evt);

        publisher.Published[0].Message.ShouldBe("v=3");
    }

    /// <summary>Passing a null publisher to the constructor throws <see cref="ArgumentNullException"/>.</summary>
    [Fact]
    public void Null_publisher_throws_ArgumentNullException()
    {
        Should.Throw<ArgumentNullException>(() => new ScriptLogTopicSink(null!));
    }
}
