using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

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

/// <summary>
///     T0-4 — parity tests for <see cref="CalculationEvaluator"/> against the VirtualTag
///     <c>RoslynVirtualTagEvaluator</c> it mirrors: the passthrough fast-path, the wall-clock timeout,
///     sandbox-violation rejection, single-tag <c>SetVirtualTag</c> drop, compile-error surfacing, and
///     the compile-cache reuse / clear contract. All script faults surface as
///     <c>VirtualTagEvalResult.Failure</c> (never a thrown exception) so WP7 can map them to Bad quality.
/// </summary>
public sealed class CalculationEvaluatorTests
{
    /// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on script logging.</summary>
    private static ScriptRootLogger NoOpScriptRoot() =>
        new(new LoggerConfiguration().CreateLogger());

    // ── passthrough fast-path ───────────────────────────────────────────────────────────────

    /// <summary>The mirror shape <c>return ctx.GetTag("X").Value;</c> returns the dependency value
    /// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path).</summary>
    [Fact]
    public void Passthrough_returns_dependency_value_without_compiling()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            calcTagId: "calc-mirror",
            scriptSource: "return ctx.GetTag(\"a\").Value;",
            dependencies: new Dictionary<string, object?> { ["a"] = 42 });

        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(42);
    }

    /// <summary>Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a
    /// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation.</summary>
    [Fact]
    public void Passthrough_does_not_populate_the_compiled_script_cache()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;",
            new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(0);

        sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;",
            new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(1);
    }

    // ── timeout ─────────────────────────────────────────────────────────────────────────────

    /// <summary>An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead
    /// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs.</summary>
    [Fact]
    public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang()
    {
        using var sut = new CalculationEvaluator(
            NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));

        var evalTask = Task.Run(
            () => sut.Evaluate("calc-loop", "while(true){}return 0;", new Dictionary<string, object?>()),
            TestContext.Current.CancellationToken);

        var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("timed out");
    }

    // ── sandbox violation ───────────────────────────────────────────────────────────────────

    /// <summary>A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects
    /// it: the <c>ScriptSandboxViolationException</c> from compile is caught and returned as a Failure whose
    /// reason names the sandbox violation — never propagated as an exception.</summary>
    [Fact]
    public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            calcTagId: "calc-fileio",
            scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");",
            dependencies: new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("sandbox violation");
    }

    // ── single-tag mode: ctx.SetVirtualTag drop ─────────────────────────────────────────────

    /// <summary>Single-tag mode: a script calling <c>ctx.SetVirtualTag</c> has that cross-tag write dropped
    /// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved.</summary>
    [Fact]
    public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved()
    {
        var logger = new CapturingLogger();
        using var sut = new CalculationEvaluator(logger, NoOpScriptRoot());

        var result = sut.Evaluate(
            calcTagId: "calc-fanout",
            scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;",
            dependencies: new Dictionary<string, object?> { ["a"] = 41 });

        // The write to "other" is dropped, but the calc tag's own output flows through unaffected.
        result.Success.ShouldBeTrue(result.Reason);
        result.Value.ShouldBe(42);

        // The drop was logged (single-tag evaluator), never applied.
        logger.Entries.ShouldContain(e =>
            e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other"));
    }

    // ── compile error ───────────────────────────────────────────────────────────────────────

    /// <summary>A compile error returns Failure with a descriptive reason (parity with the VT evaluator).</summary>
    [Fact]
    public void Evaluate_compile_error_returns_Failure_with_reason()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("compile");
    }

    /// <summary>A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator).</summary>
    [Fact]
    public void Evaluate_runtime_exception_returns_Failure_with_reason()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        var result = sut.Evaluate(
            calcTagId: "calc-div0",
            scriptSource: "int a = 0; return 1 / a;",
            dependencies: new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason.ShouldNotBeNull();
        result.Reason.ShouldContain("threw");
    }

    // ── cache reuse + clear ─────────────────────────────────────────────────────────────────

    /// <summary>A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the
    /// same source produce fresh values but leave the cache at exactly one entry.</summary>
    [Fact]
    public void Evaluate_caches_compiled_expression_across_calls()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
        const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";

        var first = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
        var second = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });

        first.Success.ShouldBeTrue(first.Reason);
        first.Value.ShouldBe(10);
        second.Success.ShouldBeTrue(second.Reason);
        second.Value.ShouldBe(14);
        CompiledCacheCount(sut).ShouldBe(1);
    }

    /// <summary><see cref="CalculationEvaluator.ClearCompiledScripts"/> drops every cached compile — the hook
    /// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles
    /// cleanly afterward.</summary>
    [Fact]
    public void ClearCompiledScripts_empties_cache_then_recompiles()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
        const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";

        sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 1 });
        CompiledCacheCount(sut).ShouldBe(1);

        sut.ClearCompiledScripts();
        CompiledCacheCount(sut).ShouldBe(0);

        var again = sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 41 });
        again.Success.ShouldBeTrue(again.Reason);
        again.Value.ShouldBe(42);
        CompiledCacheCount(sut).ShouldBe(1);
    }

    // ── guard rails ─────────────────────────────────────────────────────────────────────────

    /// <summary>Empty / whitespace scripts return Failure (parity with the VT evaluator).</summary>
    [Fact]
    public void Evaluate_empty_expression_returns_Failure()
    {
        using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());

        sut.Evaluate("calc-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
        sut.Evaluate("calc-empty", "   ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
    }

    /// <summary>Evaluation after disposal returns Failure ("disposed") rather than throwing.</summary>
    [Fact]
    public void Evaluate_after_dispose_returns_Failure()
    {
        var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
        sut.Dispose();

        var result = sut.Evaluate("calc", "return 1;", new Dictionary<string, object?>());

        result.Success.ShouldBeFalse();
        result.Reason!.ShouldContain("disposed");
    }

    /// <summary>Reads the count of the private <c>CompiledScriptCache</c> (which exposes a <c>Count</c>
    /// property) via reflection — mirrors the VT evaluator test's cache-count probe.</summary>
    private static int CompiledCacheCount(CalculationEvaluator sut)
    {
        var field = typeof(CalculationEvaluator)
            .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic);
        field.ShouldNotBeNull();
        var cache = field.GetValue(sut)!;
        var countProp = cache.GetType().GetProperty("Count");
        countProp.ShouldNotBeNull();
        return (int)countProp.GetValue(cache)!;
    }

    /// <summary>Minimal <see cref="ILogger{T}"/> that records emitted entries so the SetVirtualTag-drop
    /// test can assert the Debug "dropped" line was logged.</summary>
    private sealed class CapturingLogger : ILogger<CalculationEvaluator>
    {
        public List<(LogLevel Level, string Message)> Entries { get; } = [];

        public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
            Func<TState, Exception?, string> formatter) =>
            Entries.Add((logLevel, formatter(state, exception)));
    }
}
