using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;

namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;

/// <summary>
/// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The
/// reusable named JSON-Schema library (<see cref="SharedSchema"/> +
/// <see cref="ISharedSchemaRepository"/>, T32a) gains its authoring surface here:
/// list/get are read-only (any authenticated user), create/update/delete are
/// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique
/// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed
/// DatabaseConnection handler idiom (direct repo writes + audit).
/// </summary>
public class SchemaLibraryHandlerTests : TestKit, IDisposable
{
    private readonly ISharedSchemaRepository _schemaRepo;
    private readonly IAuditService _auditService;
    private readonly ServiceCollection _services;

    public SchemaLibraryHandlerTests()
    {
        _schemaRepo = Substitute.For<ISharedSchemaRepository>();
        _auditService = Substitute.For<IAuditService>();

        _services = new ServiceCollection();
        _services.AddScoped(_ => _schemaRepo);
        _services.AddScoped(_ => _auditService);
    }

    private IActorRef CreateActor()
    {
        var sp = _services.BuildServiceProvider();
        return Sys.ActorOf(Props.Create(() => new ManagementActor(
            sp, NullLogger<ManagementActor>.Instance)));
    }

    private static ManagementEnvelope Envelope(object command, params string[] roles) =>
        new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
            command, Guid.NewGuid().ToString("N"));

    void IDisposable.Dispose() => Shutdown();

    private const string ValidSchema =
        """{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""";

    // ── Gating ──────────────────────────────────────────────────────────────

    [Fact]
    public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new UpdateSharedSchemaCommand(1, "Addr", null, ValidSchema));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new DeleteSharedSchemaCommand(1), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    // ── List / Get (read-gated) ─────────────────────────────────────────────

    [Fact]
    public void ListSharedSchemas_WithNoRoles_ReturnsSuccess()
    {
        _schemaRepo.ListAsync(Arg.Any<CancellationToken>())
            .Returns(new List<SharedSchema>
            {
                new() { Id = 1, Name = "Addr", SchemaJson = ValidSchema },
            });

        var actor = CreateActor();
        var envelope = Envelope(new ListSharedSchemasCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Contains("Addr", response.JsonData);
    }

    [Fact]
    public void GetSharedSchema_WithNoRoles_ReturnsSuccess()
    {
        _schemaRepo.GetByIdAsync(7, Arg.Any<CancellationToken>())
            .Returns(new SharedSchema { Id = 7, Name = "Addr", SchemaJson = ValidSchema });

        var actor = CreateActor();
        var envelope = Envelope(new GetSharedSchemaCommand(7));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("Addr", response.JsonData);
    }

    // ── Create round-trip + validation ──────────────────────────────────────

    [Fact]
    public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity()
    {
        SharedSchema? saved = null;
        _schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
            .Returns((SharedSchema?)null);
        _schemaRepo.AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>())
            .Returns(ci => { saved = ci.Arg<SharedSchema>(); saved.Id = 42; return 42; });

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSharedSchemaCommand("Addr", "global", ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(saved);
        Assert.Equal("Addr", saved!.Name);
        Assert.Equal("global", saved.Scope);
        Assert.Equal(ValidSchema, saved.SchemaJson);
        // Returned payload carries the store-generated id.
        Assert.Contains("42", response.JsonData);
    }

    [Fact]
    public void CreateSharedSchema_WithDuplicateName_ReturnsClearError()
    {
        _schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
            .Returns(new SharedSchema { Id = 1, Name = "Addr", SchemaJson = ValidSchema });

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Addr", response.Error);
        Assert.Contains("already exists", response.Error);
    }

    [Fact]
    public void CreateSharedSchema_WithEmptyName_ReturnsClearError()
    {
        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSharedSchemaCommand("   ", null, ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void CreateSharedSchema_WithInvalidJson_ReturnsClearError()
    {
        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSharedSchemaCommand("Addr", null, "{ not valid json"), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("schema", response.Error, StringComparison.OrdinalIgnoreCase);
        // The repo must never be hit when the schema fails validation.
        _schemaRepo.DidNotReceive().AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>());
    }

    // ── Update round-trip + validation ──────────────────────────────────────

    [Fact]
    public void UpdateSharedSchema_WithDesignerRole_PersistsChanges()
    {
        var existing = new SharedSchema { Id = 5, Name = "Addr", Scope = null, SchemaJson = ValidSchema };
        _schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
        _schemaRepo.GetByNameAsync("Address", Arg.Any<CancellationToken>())
            .Returns((SharedSchema?)null);

        const string updatedSchema =
            """{"type":"object","properties":{"city":{"type":"string"}}}""";

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSharedSchemaCommand(5, "Address", "us", updatedSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("Address", existing.Name);
        Assert.Equal("us", existing.Scope);
        Assert.Equal(updatedSchema, existing.SchemaJson);
    }

    [Fact]
    public void UpdateSharedSchema_NotFound_ReturnsClearError()
    {
        _schemaRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
            .Returns((SharedSchema?)null);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSharedSchemaCommand(99, "Addr", null, ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("99", response.Error);
    }

    [Fact]
    public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError()
    {
        var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
        _schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
        // A DIFFERENT row already owns "Other".
        _schemaRepo.GetByNameAsync("Other", Arg.Any<CancellationToken>())
            .Returns(new SharedSchema { Id = 6, Name = "Other", SchemaJson = ValidSchema });

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSharedSchemaCommand(5, "Other", null, ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("already exists", response.Error);
    }

    [Fact]
    public void UpdateSharedSchema_SameName_IsAllowed()
    {
        var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema };
        _schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
        // The unique-name check finds THIS row — must not be treated as a collision.
        _schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>()).Returns(existing);

        const string updatedSchema =
            """{"type":"object","properties":{"zip":{"type":"string"}}}""";

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSharedSchemaCommand(5, "Addr", null, updatedSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(updatedSchema, existing.SchemaJson);
    }

    // ── Delete round-trip ───────────────────────────────────────────────────

    [Fact]
    public void DeleteSharedSchema_WithDesignerRole_Deletes()
    {
        var actor = CreateActor();
        var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        _schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>());
    }

    [Fact]
    public void DeleteSharedSchema_RecordsHumanReadableNameInAudit()
    {
        // #260: the delete handler pre-fetches the row name BEFORE deleting so the
        // audit EntityName records "Addr" rather than the numeric id (mirroring the
        // Site delete handler).
        _schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema });

        var actor = CreateActor();
        var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // LogAsync(user, action, entityType, entityId, entityName, afterState, ct):
        // entityId is the id ("5"), entityName is the resolved name ("Addr").
        _auditService.Received(1).LogAsync(
            Arg.Any<string>(), "Delete", "SharedSchema", "5", "Addr",
            Arg.Any<object?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
    {
        _schemaRepo.ListAsync(Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException("db down secret detail"));

        var actor = CreateActor();
        var envelope = Envelope(new ListSharedSchemasCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.DoesNotContain("db down secret detail", response.Error);
    }

    // ── Concurrent-create race (#260) ───────────────────────────────────────

    [Fact]
    public void CreateSharedSchema_WhenInsertRacesUniqueViolation_SurfacesSanitizedError()
    {
        // Race: GetByNameAsync sees no row, but a concurrent create commits the
        // same unique Name before our AddAsync, so the INSERT hits the DB unique
        // constraint. EF surfaces this as a DbUpdateException whose message carries
        // raw DB internals (server/constraint/SQL detail). The handler does NOT
        // catch this as a curated ManagementCommandException, so the actor's
        // top-level failure path must sanitize it to a generic, correlation-tagged
        // message — never leaking the raw SQL/DB text to the caller.
        //
        // Modeled with a SqlException-shaped fault rather than a real EF
        // DbUpdateException so the ManagementService.Tests project takes no new
        // EntityFrameworkCore package reference; the actor's sanitization keys on
        // the exception NOT being a ManagementCommandException, which is identical
        // for both types — so this exercises the exact same code path.
        const string rawDbDetail =
            "Cannot insert duplicate key row in object 'dbo.SharedSchemas' with unique " +
            "index 'IX_SharedSchemas_Name'. The duplicate key value is (Addr). " +
            "INSERT INTO [SharedSchemas] ...";

        _schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
            .Returns((SharedSchema?)null);
        _schemaRepo.AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException(rawDbDetail));

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // Sanitized: the generic internal-error message, NOT the raw DB detail.
        Assert.Contains("internal error", response.Error, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("SharedSchemas", response.Error);
        Assert.DoesNotContain("IX_SharedSchemas_Name", response.Error);
        Assert.DoesNotContain("INSERT INTO", response.Error);
        Assert.DoesNotContain("duplicate key", response.Error);
    }
}
