using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;

/// <summary>
/// Verifies the inline script-source service methods on <see cref="UnsTreeService"/> that back the
/// virtual-tag modal's script-editing panel: loading a script's source + concurrency token + name,
/// counting how many virtual tags share a script, and saving an edited body (recomputing the SHA-256
/// <c>SourceHash</c>) under its own optimistic-concurrency guard.
/// </summary>
/// <remarks>
/// The EF InMemory provider honours neither a stale <c>RowVersion</c> token nor the resulting
/// <see cref="DbUpdateConcurrencyException"/> on a same-row update, exactly as the sibling
/// <c>UnsTreeServiceVirtualTagTests</c> note — so the live concurrency-conflict catch (the
/// "changed by someone else" message) cannot be exercised here and is verified against the real
/// SQL Server in Task 12. These tests cover the load, count, success, and not-found contracts, plus
/// the realistic case where the script vanished before the save lands (a concurrent delete), which
/// the service reports as a failure rather than throwing.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptSourceServiceTests
{
    private static (UnsTreeService Service, string DbName) Fresh()
    {
        var dbName = $"uns-scriptsrc-{Guid.NewGuid():N}";
        return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
    }

    /// <summary>
    /// Seeds a single script (<c>SCRIPT-1</c>) plus an area→line→equipment path carrying two virtual
    /// tags bound to it and a third bound to a different script, so the usage count is unambiguous.
    /// </summary>
    private static void SeedScriptAndUsages(string dbName)
    {
        using var db = UnsTreeTestDb.CreateNamed(dbName);
        db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
        db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
        db.Equipment.Add(new Equipment
        {
            EquipmentId = "EQ-1",
            EquipmentUuid = Guid.NewGuid(),
            UnsLineId = "LINE-1",
            Name = "machine-1",
            MachineCode = "machine_001",
        });
        db.Scripts.Add(new Script
        {
            ScriptId = "SCRIPT-1",
            Name = "compute speed",
            SourceCode = "return 1;",
            SourceHash = HashOf("return 1;"),
            Language = "CSharp",
        });
        db.Scripts.Add(new Script
        {
            ScriptId = "SCRIPT-2",
            Name = "other",
            SourceCode = "return 2;",
            SourceHash = HashOf("return 2;"),
            Language = "CSharp",
        });

        // Two virtual tags use SCRIPT-1; one uses SCRIPT-2.
        db.VirtualTags.Add(new VirtualTag
        {
            VirtualTagId = "VTAG-A", EquipmentId = "EQ-1", Name = "vt_a", DataType = "Double", ScriptId = "SCRIPT-1",
        });
        db.VirtualTags.Add(new VirtualTag
        {
            VirtualTagId = "VTAG-B", EquipmentId = "EQ-1", Name = "vt_b", DataType = "Double", ScriptId = "SCRIPT-1",
        });
        db.VirtualTags.Add(new VirtualTag
        {
            VirtualTagId = "VTAG-C", EquipmentId = "EQ-1", Name = "vt_c", DataType = "Double", ScriptId = "SCRIPT-2",
        });
        db.SaveChanges();
    }

    private static string HashOf(string source) =>
        Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));

    // ----- GetScriptSourceAsync -----

    /// <summary>Loading a known script returns its source, concurrency token, and display name.</summary>
    [Fact]
    public async Task GetScriptSource_returns_source_and_name()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        var loaded = await service.GetScriptSourceAsync("SCRIPT-1");

        loaded.ShouldNotBeNull();
        loaded.Value.SourceCode.ShouldBe("return 1;");
        loaded.Value.Name.ShouldBe("compute speed");
        loaded.Value.RowVersion.ShouldNotBeNull();
    }

    /// <summary>Loading an unknown script id returns null.</summary>
    [Fact]
    public async Task GetScriptSource_unknown_returns_null()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        var loaded = await service.GetScriptSourceAsync("SCRIPT-NOPE");

        loaded.ShouldBeNull();
    }

    // ----- CountVirtualTagsUsingScriptAsync -----

    /// <summary>The usage count reflects only the virtual tags bound to the given script.</summary>
    [Fact]
    public async Task CountVirtualTagsUsingScript_returns_count()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-1")).ShouldBe(2);
        (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-2")).ShouldBe(1);
        (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-NOPE")).ShouldBe(0);
    }

    // ----- UpdateScriptSourceAsync -----

    /// <summary>A save with the current token updates the source and recomputes the SHA-256 hash.</summary>
    [Fact]
    public async Task UpdateScriptSource_updates_source_and_recomputes_hash()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        byte[] rv;
        string originalHash;
        using (var db = UnsTreeTestDb.CreateNamed(dbName))
        {
            var script = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
            rv = script.RowVersion;
            originalHash = script.SourceHash;
        }

        const string newSource = "return 42;";
        var result = await service.UpdateScriptSourceAsync("SCRIPT-1", newSource, rv);

        result.Ok.ShouldBeTrue();
        result.Error.ShouldBeNull();

        using var verify = UnsTreeTestDb.CreateNamed(dbName);
        var after = verify.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
        after.SourceCode.ShouldBe(newSource);
        after.SourceHash.ShouldBe(HashOf(newSource));
        after.SourceHash.ShouldNotBe(originalHash);
    }

    /// <summary>Saving a script id that no longer exists returns the not-found error.</summary>
    [Fact]
    public async Task UpdateScriptSource_missing_returns_error()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        var result = await service.UpdateScriptSourceAsync("SCRIPT-NOPE", "x", []);

        result.Ok.ShouldBeFalse();
        result.Error.ShouldBe("Script not found.");
    }

    /// <summary>
    /// A save whose target row was changed/removed by someone else after the panel captured its token
    /// surfaces a non-null error instead of throwing. InMemory cannot raise the real
    /// <see cref="DbUpdateConcurrencyException"/> for a same-row stale token, so this exercises the
    /// concurrent-delete leg of the same race (the panel holds a now-stale token; the row is gone),
    /// which the service reports as a failure. The live "changed by someone else" concurrency message
    /// is verified against real SQL Server in Task 12.
    /// </summary>
    [Fact]
    public async Task UpdateScriptSource_stale_token_after_concurrent_delete_returns_error()
    {
        var (service, dbName) = Fresh();
        SeedScriptAndUsages(dbName);

        byte[] staleRv;
        using (var db = UnsTreeTestDb.CreateNamed(dbName))
        {
            staleRv = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1").RowVersion;
        }

        // Another editor removes the row out from under us before our save lands.
        using (var other = UnsTreeTestDb.CreateNamed(dbName))
        {
            var row = other.Scripts.Single(s => s.ScriptId == "SCRIPT-1");
            other.Scripts.Remove(row);
            other.SaveChanges();
        }

        var result = await service.UpdateScriptSourceAsync("SCRIPT-1", "return 99;", staleRv);

        result.Ok.ShouldBeFalse();
        result.Error.ShouldNotBeNull();
    }
}
