using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;

/// <summary>
///     Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
///     Server-015 and Server-016 audited the split lock discipline between
///     <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
///     and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
/// </summary>
public sealed class GatewaySessionTests
{
    /// <summary>
    ///     Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
    ///     <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
    ///     must not flip the session back to <see cref="SessionState.Ready"/>. The
    ///     blocking worker shutdown keeps <c>CloseAsync</c> parked between the
    ///     <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
    ///     window the audit identified.
    /// </summary>
    [Fact]
    public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
    {
        BlockingShutdownWorkerClient workerClient = new();
        GatewaySession session = CreateReadySession(workerClient);

        Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
        await workerClient.WaitForShutdownStartAsync();

        // Close has set _state = Closing under _syncRoot and is parked inside
        // worker.ShutdownAsync. A concurrent transition (e.g. a late
        // SessionWorkerClientFactory lifecycle callback) must not revive the session.
        Assert.Equal(SessionState.Closing, session.State);
        session.TransitionTo(SessionState.Ready);
        Assert.Equal(SessionState.Closing, session.State);

        workerClient.ReleaseShutdown();
        SessionCloseResult result = await closeTask;

        Assert.Equal(SessionState.Closed, result.FinalState);
        Assert.Equal(SessionState.Closed, session.State);

        await session.DisposeAsync();
    }

    /// <summary>
    ///     Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
    ///     <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
    ///     session out of <see cref="SessionState.Closed"/> either — the close path's
    ///     terminal write goes through the same <c>_syncRoot</c> the rest of the state
    ///     machine uses, so the existing "Closed is terminal" invariant holds.
    /// </summary>
    [Fact]
    public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
    {
        FakeWorkerClient workerClient = new();
        GatewaySession session = CreateReadySession(workerClient);

        await session.CloseAsync("test-close", CancellationToken.None);
        Assert.Equal(SessionState.Closed, session.State);

        session.MarkFaulted("late-fault");
        Assert.Equal(SessionState.Closed, session.State);

        await session.DisposeAsync();
    }

    /// <summary>
    ///     Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
    ///     while <see cref="GatewaySession.CloseAsync"/> is parked between its
    ///     <c>Closing</c> and <c>Closed</c> writes must not break the close path's
    ///     terminal contract: the in-flight close runs to <c>Closed</c>, the fault
    ///     reason is preserved on <see cref="GatewaySession.FinalFault"/>, and the
    ///     session does not get stuck in <see cref="SessionState.Faulted"/>. The
    ///     state machine documents "Closing only allows a transition to Closed or
    ///     Faulted" — this test pins the resolved end state so a future tightening
    ///     of <c>MarkFaulted</c> cannot silently regress it.
    /// </summary>
    [Fact]
    public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
    {
        BlockingShutdownWorkerClient workerClient = new();
        GatewaySession session = CreateReadySession(workerClient);

        Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
        await workerClient.WaitForShutdownStartAsync();

        // Close has set _state = Closing under _syncRoot and is parked inside
        // worker.ShutdownAsync. Fault the session from another thread while parked.
        Assert.Equal(SessionState.Closing, session.State);
        session.MarkFaulted("concurrent-fault");

        workerClient.ReleaseShutdown();
        SessionCloseResult result = await closeTask;

        // Close still wins — Closed is terminal — but the fault reason is preserved
        // so observers see the original cause once the session settles.
        Assert.Equal(SessionState.Closed, result.FinalState);
        Assert.Equal(SessionState.Closed, session.State);
        Assert.Equal("concurrent-fault", session.FinalFault);

        await session.DisposeAsync();
    }

    /// <summary>
    ///     Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
    ///     for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
    ///     its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
    ///     would race the dispose and raise <see cref="ObjectDisposedException"/>.
    /// </summary>
    [Fact]
    public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
    {
        BlockingShutdownWorkerClient workerClient = new();
        GatewaySession session = CreateReadySession(workerClient);

        Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
        await workerClient.WaitForShutdownStartAsync();

        // Start disposing while close is still parked inside worker.ShutdownAsync.
        ValueTask disposeTask = session.DisposeAsync();

        // Now release the worker shutdown so close can complete.
        workerClient.ReleaseShutdown();

        // Both must complete cleanly — the close's Release() must run before the
        // dispose actually tears the semaphore down.
        SessionCloseResult result = await closeTask;
        await disposeTask;

        Assert.Equal(SessionState.Closed, result.FinalState);
        Assert.Equal(1, workerClient.ShutdownCount);
        // Worker dispose ran exactly once even with the close/dispose interleave.
        Assert.Equal(1, workerClient.DisposeCount);
    }

    /// <summary>
    ///     Double-dispose is tolerated: the second call must swallow
    ///     <see cref="ObjectDisposedException"/> from the already-disposed semaphore
    ///     rather than propagating it.
    /// </summary>
    [Fact]
    public async Task DisposeAsync_CalledTwice_DoesNotThrow()
    {
        FakeWorkerClient workerClient = new();
        GatewaySession session = CreateReadySession(workerClient);
        await session.CloseAsync("test-close", CancellationToken.None);

        await session.DisposeAsync();
        // No second exception — the dispose's defensive ObjectDisposedException catch
        // covers the doubled call path that SessionManager.ShutdownAsync could trigger
        // if it re-removed a session.
        await session.DisposeAsync();
    }

    private static GatewaySession CreateReadySession(IWorkerClient workerClient)
    {
        GatewaySession session = new(
            sessionId: "session-test",
            backendName: "mxaccess",
            pipeName: "mxaccess-gateway-1-session-test",
            nonce: "nonce",
            clientIdentity: "client-1",
            clientSessionName: "test-session",
            clientCorrelationId: "client-correlation-1",
            commandTimeout: TimeSpan.FromSeconds(5),
            startupTimeout: TimeSpan.FromSeconds(5),
            shutdownTimeout: TimeSpan.FromSeconds(5),
            leaseDuration: TimeSpan.FromMinutes(30),
            openedAt: DateTimeOffset.UtcNow);
        session.AttachWorkerClient(workerClient);
        session.MarkReady();
        return session;
    }

    /// <summary>
    ///     Minimal worker client that parks <see cref="ShutdownAsync"/> until the test
    ///     explicitly releases it. Used to keep <see cref="GatewaySession.CloseAsync"/>
    ///     stuck between its <c>Closing</c> and <c>Closed</c> writes so the test can
    ///     observe and act on the intermediate state.
    /// </summary>
    private sealed class BlockingShutdownWorkerClient : IWorkerClient
    {
        private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);

        public string SessionId { get; } = "session-test";

        public int? ProcessId { get; } = 1234;

        public WorkerClientState State { get; private set; } = WorkerClientState.Ready;

        public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;

        public int ShutdownCount { get; private set; }

        public int DisposeCount { get; private set; }

        public Task WaitForShutdownStartAsync()
        {
            return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
        }

        public void ReleaseShutdown()
        {
            _shutdownReleased.TrySetResult();
        }

        public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

        public Task<WorkerCommandReply> InvokeAsync(
            WorkerCommand command,
            TimeSpan timeout,
            CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());

        public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await Task.CompletedTask.ConfigureAwait(false);
            yield break;
        }

        public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
        {
            ShutdownCount++;
            _shutdownStarted.TrySetResult();
            await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
            State = WorkerClientState.Closed;
        }

        public void Kill(string reason)
        {
            State = WorkerClientState.Faulted;
        }

        public ValueTask DisposeAsync()
        {
            DisposeCount++;
            return ValueTask.CompletedTask;
        }
    }

    private sealed class FakeWorkerClient : IWorkerClient
    {
        public string SessionId { get; } = "session-test";

        public int? ProcessId { get; } = 1234;

        public WorkerClientState State { get; } = WorkerClientState.Ready;

        public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;

        public int DisposeCount { get; private set; }

        public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

        public Task<WorkerCommandReply> InvokeAsync(
            WorkerCommand command,
            TimeSpan timeout,
            CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());

        public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            await Task.CompletedTask.ConfigureAwait(false);
            yield break;
        }

        public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;

        public void Kill(string reason)
        {
        }

        public ValueTask DisposeAsync()
        {
            DisposeCount++;
            return ValueTask.CompletedTask;
        }
    }
}
