using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

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

/// <summary>
///     Verifies the sink that mirrors script Error+ events to the main log at Warning
///     level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
///     while genuine script failures DO surface there so operators see them without
///     watching a separate log file.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogCompanionSinkTests
{
    private sealed class CapturingSink : ILogEventSink
    {
        public List<LogEvent> Events { get; } = [];
        public void Emit(LogEvent logEvent) => Events.Add(logEvent);
    }

    private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
    {
        // Main logger captures companion forwards.
        var mainSink = new CapturingSink();
        var mainLogger = new LoggerConfiguration()
            .MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();

        // Script logger fans out to scripts file (here: capture sink) + the companion sink.
        var scriptSink = new CapturingSink();
        var scriptLogger = new LoggerConfiguration()
            .MinimumLevel.Verbose()
            .WriteTo.Sink(scriptSink)
            .WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
            .CreateLogger();

        return (scriptLogger, scriptSink, mainSink);
    }

    [Fact]
    public void Info_event_lands_in_scripts_sink_but_not_in_main()
    {
        var (script, scriptSink, mainSink) = BuildPipeline();
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");

        scriptSink.Events.Count.ShouldBe(1);
        mainSink.Events.Count.ShouldBe(0);
    }

    [Fact]
    public void Warning_event_lands_in_scripts_sink_but_not_in_main()
    {
        var (script, scriptSink, mainSink) = BuildPipeline();
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");

        scriptSink.Events.Count.ShouldBe(1);
        mainSink.Events.Count.ShouldBe(0);
    }

    [Fact]
    public void Error_event_mirrored_to_main_at_Warning_level()
    {
        var (script, scriptSink, mainSink) = BuildPipeline();
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
              .Error("condition script failed");

        scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
        mainSink.Events.Count.ShouldBe(1);
        mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
    }

    [Fact]
    public void Mirrored_event_includes_ScriptName_and_original_level()
    {
        var (script, _, mainSink) = BuildPipeline();
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
              .Error("temp exceeded limit");

        var forwarded = mainSink.Events[0];
        forwarded.Properties.ShouldContainKey("ScriptName");
        ((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
        forwarded.Properties.ShouldContainKey("OriginalLevel");
        ((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
    }

    [Fact]
    public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
    {
        var (script, _, mainSink) = BuildPipeline();
        var ex = new InvalidOperationException("user code threw");
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");

        mainSink.Events.Count.ShouldBe(1);
        mainSink.Events[0].Exception.ShouldBeSameAs(ex);
    }

    [Fact]
    public void Fatal_event_mirrored_just_like_Error()
    {
        var (script, _, mainSink) = BuildPipeline();
        script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
        mainSink.Events.Count.ShouldBe(1);
        mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
    }

    [Fact]
    public void Missing_ScriptName_property_falls_back_to_unknown()
    {
        var (_, _, mainSink) = BuildPipeline();
        // Log without the ScriptName property to simulate a direct root-logger call
        // that bypassed the factory (defensive — shouldn't normally happen).
        var mainLogger = new LoggerConfiguration().CreateLogger();
        var companion = new ScriptLogCompanionSink(Log.Logger);

        // Build an event manually so we can omit the property.
        var ev = new LogEvent(
            timestamp: DateTimeOffset.UtcNow,
            level: LogEventLevel.Error,
            exception: null,
            messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
            properties: []);
        // Direct test: sink should not throw + message should be well-formed.
        Should.NotThrow(() => companion.Emit(ev));
    }

    [Fact]
    public void Null_main_logger_rejected()
    {
        Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
    }

    [Fact]
    public void Custom_mirror_threshold_applied()
    {
        // Caller can raise the mirror threshold to Fatal if they want only
        // catastrophic events in the main log.
        var mainSink = new CapturingSink();
        var mainLogger = new LoggerConfiguration()
            .MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();

        var scriptLogger = new LoggerConfiguration()
            .MinimumLevel.Verbose()
            .WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
            .CreateLogger();

        scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
        mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");

        scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
        mainSink.Events.Count.ShouldBe(1);
    }
}
