using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Redundancy;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Security.Auth;

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

/// <summary>
/// Covers the Trigger-failover control's behaviour. The repo has no bUnit (see
/// <c>PageAuthorizationGuardTests</c>), so the consequential parts — the peer guard, the confirm
/// flow, and the refused-vs-succeeded outcome — live in a pure model that the razor is a shell over,
/// and are tested here rather than left to live verification alone.
/// </summary>
public sealed class ManualFailoverPageModelTests
{
    private sealed class FakeFailoverService : IManualFailoverService
    {
        public ManualFailoverSnapshot Next { get; set; } = new("akka.tcp://otopcua@a:4053", new[]
        {
            "akka.tcp://otopcua@a:4053", "akka.tcp://otopcua@b:4053",
        });

        public int FailOverCalls { get; private set; }
        public List<string> Actors { get; } = new();
        public string? Result { get; set; } = "akka.tcp://otopcua@a:4053";
        public Exception? Throw { get; set; }
        public Exception? ThrowOnSnapshot { get; set; }

        public ManualFailoverSnapshot GetSnapshot()
            => ThrowOnSnapshot is not null ? throw ThrowOnSnapshot : Next;

        public Task<string?> FailOverDriverPrimaryAsync(string actor)
        {
            FailOverCalls++;
            Actors.Add(actor);
            if (Throw is not null) throw Throw;
            return Task.FromResult(Result);
        }
    }

    private static (ManualFailoverPageModel Model, FakeFailoverService Service) Build()
    {
        var svc = new FakeFailoverService();
        var model = new ManualFailoverPageModel(svc);
        model.Refresh();
        return (model, svc);
    }

    /// <summary>
    /// The control is Administrator-only. Not <see cref="AdminUiPolicies.ConfigEditor"/> — which the
    /// neighbouring cluster-authoring pages use, and which also admits Designer: this restarts a
    /// production node rather than editing configuration.
    /// </summary>
    [Fact]
    public void Control_is_gated_on_the_fleet_admin_policy()
    {
        ManualFailoverPageModel.RequiredPolicy.ShouldBe(AdminUiPolicies.FleetAdmin);
        ManualFailoverPageModel.RequiredPolicy.ShouldNotBe(AdminUiPolicies.ConfigEditor);
    }

    /// <summary>With a peer present the button is live and explains nothing away.</summary>
    [Fact]
    public void Button_is_enabled_when_a_driver_peer_exists()
    {
        var (model, _) = Build();

        model.CanFailOver.ShouldBeTrue();
        model.DisabledReason.ShouldBeNull();
        model.Snapshot!.PrimaryAddress.ShouldBe("akka.tcp://otopcua@a:4053");
    }

    /// <summary>
    /// THE peer guard. On a lone driver node a "failover" is a shutdown: the button must be disabled
    /// and say why, and requesting it must not even open the dialog.
    /// </summary>
    [Fact]
    public void Button_is_disabled_with_a_reason_when_there_is_no_peer()
    {
        var (model, svc) = Build();
        svc.Next = new ManualFailoverSnapshot("akka.tcp://otopcua@a:4053", new[] { "akka.tcp://otopcua@a:4053" });
        model.Refresh();

        model.CanFailOver.ShouldBeFalse();
        model.DisabledReason.ShouldNotBeNull().ShouldContain("1 Up driver member");

        model.RequestFailover();
        model.ConfirmOpen.ShouldBeFalse("a guarded-off control must not open its confirmation dialog");
    }

    /// <summary>The confirm flow calls the service exactly once, with the authenticated user.</summary>
    [Fact]
    public async Task Confirm_flow_calls_the_service_exactly_once()
    {
        var (model, svc) = Build();

        model.RequestFailover();
        model.ConfirmOpen.ShouldBeTrue();
        await model.ConfirmFailoverAsync("alice");

        svc.FailOverCalls.ShouldBe(1);
        svc.Actors.ShouldBe(new[] { "alice" });
        model.ConfirmOpen.ShouldBeFalse();
        model.StatusIsError.ShouldBeFalse();
        model.StatusMessage.ShouldNotBeNull().ShouldContain("akka.tcp://otopcua@a:4053");
    }

    /// <summary>
    /// Confirming without an open dialog does nothing — a stray double-submit on the circuit must not
    /// bounce a second node.
    /// </summary>
    [Fact]
    public async Task Confirming_twice_only_fails_over_once()
    {
        var (model, svc) = Build();

        model.RequestFailover();
        await model.ConfirmFailoverAsync("alice");
        await model.ConfirmFailoverAsync("alice");

        svc.FailOverCalls.ShouldBe(1);
    }

    /// <summary>Cancelling closes the dialog and calls nothing.</summary>
    [Fact]
    public async Task Cancel_does_not_call_the_service()
    {
        var (model, svc) = Build();

        model.RequestFailover();
        model.CancelFailover();
        await model.ConfirmFailoverAsync("alice");

        model.ConfirmOpen.ShouldBeFalse();
        svc.FailOverCalls.ShouldBe(0);
    }

    /// <summary>
    /// The service re-evaluates the peer guard against live state, which can have changed since the
    /// page rendered. A refusal must surface as a failure — reporting it as a success would tell an
    /// operator the Primary moved when it did not.
    /// </summary>
    [Fact]
    public async Task Refusal_is_reported_as_an_error_not_a_success()
    {
        var (model, svc) = Build();
        svc.Result = null;

        model.RequestFailover();
        await model.ConfirmFailoverAsync("alice");

        model.StatusIsError.ShouldBeTrue();
        model.StatusMessage.ShouldNotBeNull().ShouldContain("refused");
    }

    /// <summary>A throwing service surfaces as an error, not an unhandled circuit exception.</summary>
    [Fact]
    public async Task Service_failure_is_surfaced_not_thrown()
    {
        var (model, svc) = Build();
        svc.Throw = new InvalidOperationException("cluster gone");

        model.RequestFailover();
        await model.ConfirmFailoverAsync("alice");

        model.StatusIsError.ShouldBeTrue();
        model.StatusMessage.ShouldNotBeNull().ShouldContain("cluster gone");
    }

    /// <summary>
    /// An unreadable cluster renders a disabled control, not a 500 — the page is also the place an
    /// operator looks when the node is unhealthy.
    /// </summary>
    [Fact]
    public void Unreadable_cluster_state_disables_the_control_instead_of_throwing()
    {
        var svc = new FakeFailoverService { ThrowOnSnapshot = new InvalidOperationException("no cluster") };
        var model = new ManualFailoverPageModel(svc);

        Should.NotThrow(model.Refresh);
        model.Snapshot.ShouldBeNull();
        model.CanFailOver.ShouldBeFalse();
        model.DisabledReason.ShouldNotBeNull().ShouldContain("unavailable");
    }
}
