using Microsoft.CodeAnalysis.Scripting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;

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

/// <summary>
///     Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
///     (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
///     Locks decision #6 — scripts can't escape to the broader .NET surface.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptSandboxTests
{
    [Fact]
    public void Happy_path_script_compiles_and_returns()
    {
        // Baseline — ctx + Math + basic types must work.
        var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
            """
            var v = (double)ctx.GetTag("X").Value;
            return Math.Abs(v) * 2.0;
            """);
        evaluator.ShouldNotBeNull();
    }

    [Fact]
    public async Task Happy_path_script_runs_and_reads_seeded_tag()
    {
        var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
            """return (double)ctx.GetTag("In").Value * 2.0;""");

        var ctx = new FakeScriptContext().Seed("In", 21.0);
        var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
        result.ShouldBe(42.0);
    }

    [Fact]
    public async Task SetVirtualTag_records_the_write()
    {
        var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
            """
            ctx.SetVirtualTag("Out", 42);
            return 0;
            """);
        var ctx = new FakeScriptContext();
        await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
        ctx.Writes.Count.ShouldBe(1);
        ctx.Writes[0].Path.ShouldBe("Out");
        ctx.Writes[0].Value.ShouldBe(42);
    }

    [Fact]
    public void Rejects_File_IO_at_compile()
    {
        Should.Throw<ScriptSandboxViolationException>(() =>
            ScriptEvaluator<FakeScriptContext, string>.Compile(
                """return System.IO.File.ReadAllText("c:/secrets.txt");"""));
    }

    [Fact]
    public void Rejects_HttpClient_at_compile()
    {
        Should.Throw<ScriptSandboxViolationException>(() =>
            ScriptEvaluator<FakeScriptContext, int>.Compile(
                """
                var c = new System.Net.Http.HttpClient();
                return 0;
                """));
    }

    [Fact]
    public void Rejects_Process_Start_at_compile()
    {
        Should.Throw<ScriptSandboxViolationException>(() =>
            ScriptEvaluator<FakeScriptContext, int>.Compile(
                """
                System.Diagnostics.Process.Start("cmd.exe");
                return 0;
                """));
    }

    [Fact]
    public void Rejects_Reflection_Assembly_Load_at_compile()
    {
        Should.Throw<ScriptSandboxViolationException>(() =>
            ScriptEvaluator<FakeScriptContext, int>.Compile(
                """
                System.Reflection.Assembly.Load("System.Core");
                return 0;
                """));
    }

    [Fact]
    public void Rejects_Environment_GetEnvironmentVariable_at_compile()
    {
        // Environment lives in System.Private.CoreLib (allow-listed for primitives) —
        // BUT calling .GetEnvironmentVariable exposes process state we don't want in
        // scripts. In an allow-list sandbox this passes because mscorlib is allowed;
        // relying on ScriptSandbox alone isn't enough for the Environment class. We
        // document here that the CURRENT sandbox allows Environment — acceptable because
        // Environment doesn't leak outside the process boundary, doesn't side-effect
        // persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
        // reflection specifically.
        //
        // This test LOCKS that compromise: operators should not be surprised if a
        // script reads an env var. If we later decide to tighten, this test flips.
        var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
            """return System.Environment.GetEnvironmentVariable("PATH");""");
        evaluator.ShouldNotBeNull();
    }

    [Fact]
    public async Task Script_exception_propagates_unwrapped()
    {
        var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
            """throw new InvalidOperationException("boom");""");
        await Should.ThrowAsync<InvalidOperationException>(async () =>
            await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
    }

    [Fact]
    public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
    {
        // Scripts that need a timestamp go through ctx.Now so tests can pin it.
        var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
        evaluator.ShouldNotBeNull();
    }

    [Fact]
    public void Deadband_helper_is_reachable_from_scripts()
    {
        var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
            """return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
        evaluator.ShouldNotBeNull();
    }

    [Fact]
    public async Task Linq_Enumerable_is_available_from_scripts()
    {
        // LINQ is in the allow-list because SCADA math frequently wants Sum / Average
        // / Where. Confirm it works.
        var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
            """
            var nums = new[] { 1, 2, 3, 4, 5 };
            return nums.Where(n => n > 2).Sum();
            """);
        var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
        result.ShouldBe(12);
    }

    [Fact]
    public async Task DataValueSnapshot_is_usable_in_scripts()
    {
        // ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
        var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
            """
            var v = ctx.GetTag("T");
            return v.StatusCode == 0;
            """);
        var ctx = new FakeScriptContext().Seed("T", 5.0);
        var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
        result.ShouldBeTrue();
    }

    [Fact]
    public void Compile_error_gives_location_in_diagnostics()
    {
        // Compile errors must carry the source span so the Admin UI can point at them.
        try
        {
            ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
            Assert.Fail("expected CompilationErrorException");
        }
        catch (CompilationErrorException ex)
        {
            ex.Diagnostics.ShouldNotBeEmpty();
            ex.Diagnostics[0].Location.ShouldNotBeNull();
        }
    }
}
