package com.zb.mom.ww.mxgateway.client;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.grpc.Context;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserReply;
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxEventFamily;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import org.junit.jupiter.api.Test;

final class MxGatewayClientSessionTests {
    @Test
    void unaryCallsCarryAuthMetadataAndDeadline() throws Exception {
        AtomicReference<String> authorization = new AtomicReference<>();
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        AtomicReference<Boolean> deadlineSeen = new AtomicReference<>(false);

        TestGatewayService service = new TestGatewayService() {
            @Override
            public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
                deadlineSeen.set(Context.current().getDeadline() != null);
                responseObserver.onNext(OpenSessionReply.newBuilder()
                        .setSessionId("session-java")
                        .setProtocolStatus(ok())
                        .build());
                responseObserver.onCompleted();
            }

            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                commandRequest.set(request);
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok())
                        .setRegister(RegisterReply.newBuilder().setServerHandle(42))
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, authorization);
                MxGatewayClient client = gateway.client("mxgw_visible_secret", Duration.ofSeconds(5))) {
            MxGatewaySession session = client.openSession("junit-session");

            int serverHandle = session.register("java-test-client");

            assertEquals(42, serverHandle);
            assertEquals("Bearer mxgw_visible_secret", authorization.get());
            assertEquals("session-java", commandRequest.get().getSessionId());
            assertEquals(MxCommandKind.MX_COMMAND_KIND_REGISTER, commandRequest.get().getCommand().getKind());
            assertTrue(deadlineSeen.get());
        }
    }

    @Test
    void methodHelpersReturnTypedHandlesAndRawReplies() throws Exception {
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                MxCommandReply.Builder reply = MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok());
                if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ADD_ITEM) {
                    reply.setAddItem(AddItemReply.newBuilder().setItemHandle(7));
                }
                responseObserver.onNext(reply.build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");

            int itemHandle = session.addItem(12, "TestObject.TestInt");
            MxCommandReply raw = session.adviseRaw(12, itemHandle);

            assertEquals(7, itemHandle);
            assertEquals(MxCommandKind.MX_COMMAND_KIND_ADVISE, raw.getKind());
        }
    }

    @Test
    void subscribeBulkBuildsOneBulkCommandAndReturnsResults() throws Exception {
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                commandRequest.set(request);
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok())
                        .setSubscribeBulk(BulkSubscribeReply.newBuilder()
                                .addResults(SubscribeResult.newBuilder()
                                        .setServerHandle(12)
                                        .setTagAddress("Area001.Pump001.Speed")
                                        .setItemHandle(34)
                                        .setWasSuccessful(true)))
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");

            List<SubscribeResult> results = session.subscribeBulk(12, List.of("Area001.Pump001.Speed"));

            assertEquals(34, results.get(0).getItemHandle());
            assertEquals(MxCommandKind.MX_COMMAND_KIND_SUBSCRIBE_BULK, commandRequest.get().getCommand().getKind());
            assertEquals(
                    List.of("Area001.Pump001.Speed"),
                    commandRequest.get().getCommand().getSubscribeBulk().getTagAddressesList());
        }
    }

    @Test
    void streamCancellationCancelsServerCall() throws Exception {
        CountDownLatch cancelled = new CountDownLatch(1);
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
                ServerCallStreamObserver<MxEvent> serverObserver =
                        (ServerCallStreamObserver<MxEvent>) responseObserver;
                serverObserver.setOnCancelHandler(cancelled::countDown);
                responseObserver.onNext(MxEvent.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setWorkerSequence(1)
                        .build());
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxEventStream events = MxGatewaySession.forSessionId(client, "stream-session").streamEvents();

            assertTrue(events.hasNext());
            assertEquals(1, events.next().getWorkerSequence());
            events.close();

            assertTrue(cancelled.await(5, TimeUnit.SECONDS));
        }
    }

    @Test
    void queryActiveAlarmsForwardsRequestAndStreamsSnapshots() throws Exception {
        AtomicReference<QueryActiveAlarmsRequest> queryRequest = new AtomicReference<>();
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void queryActiveAlarms(
                    QueryActiveAlarmsRequest request, StreamObserver<ActiveAlarmSnapshot> responseObserver) {
                queryRequest.set(request);
                responseObserver.onNext(ActiveAlarmSnapshot.newBuilder()
                        .setAlarmFullReference("Galaxy!TestArea.TestMachine_001.HiTemp")
                        .setSourceObjectReference("TestMachine_001")
                        .setAlarmTypeName("HiAlarm")
                        .setSeverity(800)
                        .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
                        .setCategory("Process")
                        .setDescription("High temperature alarm")
                        .build());
                responseObserver.onNext(ActiveAlarmSnapshot.newBuilder()
                        .setAlarmFullReference("Galaxy!TestArea.TestMachine_002.LoTemp")
                        .setSourceObjectReference("TestMachine_002")
                        .setAlarmTypeName("LoAlarm")
                        .setSeverity(500)
                        .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE_ACKED)
                        .setOperatorUser("operator-1")
                        .setOperatorComment("acknowledged")
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            CountDownLatch completed = new CountDownLatch(1);
            java.util.List<ActiveAlarmSnapshot> received = new java.util.ArrayList<>();
            AtomicReference<Throwable> errorRef = new AtomicReference<>();

            QueryActiveAlarmsRequest request = QueryActiveAlarmsRequest.newBuilder()
                    .setSessionId("alarm-session")
                    .setClientCorrelationId("test-corr-1")
                    .setAlarmFilterPrefix("Galaxy!TestArea")
                    .build();

            MxGatewayActiveAlarmsSubscription subscription = client.queryActiveAlarms(
                    request,
                    new StreamObserver<>() {
                        @Override
                        public void onNext(ActiveAlarmSnapshot value) {
                            received.add(value);
                        }

                        @Override
                        public void onError(Throwable t) {
                            errorRef.set(t);
                            completed.countDown();
                        }

                        @Override
                        public void onCompleted() {
                            completed.countDown();
                        }
                    });

            assertTrue(completed.await(5, TimeUnit.SECONDS));
            subscription.close();

            assertNotNull(queryRequest.get());
            assertEquals("alarm-session", queryRequest.get().getSessionId());
            assertEquals("test-corr-1", queryRequest.get().getClientCorrelationId());
            assertEquals("Galaxy!TestArea", queryRequest.get().getAlarmFilterPrefix());

            assertEquals(2, received.size());
            assertEquals("Galaxy!TestArea.TestMachine_001.HiTemp", received.get(0).getAlarmFullReference());
            assertEquals(
                    AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE,
                    received.get(0).getCurrentState());
            assertEquals(800, received.get(0).getSeverity());
            assertEquals("Galaxy!TestArea.TestMachine_002.LoTemp", received.get(1).getAlarmFullReference());
            assertEquals(
                    AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE_ACKED,
                    received.get(1).getCurrentState());
            assertEquals("operator-1", received.get(1).getOperatorUser());
            assertNull(errorRef.get());
        }
    }

    @Test
    void streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages() throws Exception {
        AtomicReference<StreamAlarmsRequest> streamRequest = new AtomicReference<>();
        CountDownLatch serverCancelled = new CountDownLatch(1);
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void streamAlarms(
                    StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
                streamRequest.set(request);
                ServerCallStreamObserver<AlarmFeedMessage> server =
                        (ServerCallStreamObserver<AlarmFeedMessage>) responseObserver;
                server.setOnCancelHandler(serverCancelled::countDown);
                // Active-alarm snapshot, snapshot-complete sentinel, then a
                // transition — mirrors the shape of a real alarm feed open.
                server.onNext(AlarmFeedMessage.newBuilder()
                        .setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
                                .setAlarmFullReference("Tank01.Level.HiHi")
                                .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
                                .setSeverity(700))
                        .build());
                server.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
                server.onNext(AlarmFeedMessage.newBuilder()
                        .setTransition(OnAlarmTransitionEvent.newBuilder()
                                .setAlarmFullReference("Tank01.Level.HiHi")
                                .setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
                                .setSeverity(700))
                        .build());
                // Note: we deliberately do NOT call onCompleted() so the call
                // remains open for the cancellation assertion below.
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            java.util.List<AlarmFeedMessage> received = new java.util.ArrayList<>();
            AtomicReference<Throwable> errorRef = new AtomicReference<>();
            CountDownLatch threeReceived = new CountDownLatch(3);

            StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
                    .setAlarmFilterPrefix("Tank01")
                    .build();

            MxGatewayAlarmFeedSubscription subscription = client.streamAlarms(
                    request,
                    new StreamObserver<>() {
                        @Override
                        public void onNext(AlarmFeedMessage value) {
                            received.add(value);
                            threeReceived.countDown();
                        }

                        @Override
                        public void onError(Throwable t) {
                            errorRef.set(t);
                        }

                        @Override
                        public void onCompleted() {
                        }
                    });

            assertTrue(threeReceived.await(5, TimeUnit.SECONDS),
                    "expected three alarm feed messages within 5s");

            // The request shape (filter prefix in particular) must reach the
            // server — proves MxGatewayClient.streamAlarms calls the production
            // subscription.wrap(observer) glue and not a CLI override.
            assertNotNull(streamRequest.get());
            assertEquals("Tank01", streamRequest.get().getAlarmFilterPrefix());

            // Order and payload-case must be preserved (the wrapping observer
            // is just a pass-through).
            assertEquals(3, received.size());
            assertEquals(AlarmFeedMessage.PayloadCase.ACTIVE_ALARM, received.get(0).getPayloadCase());
            assertEquals(
                    "Tank01.Level.HiHi",
                    received.get(0).getActiveAlarm().getAlarmFullReference());
            assertEquals(AlarmFeedMessage.PayloadCase.SNAPSHOT_COMPLETE, received.get(1).getPayloadCase());
            assertEquals(AlarmFeedMessage.PayloadCase.TRANSITION, received.get(2).getPayloadCase());
            assertEquals(
                    AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE,
                    received.get(2).getTransition().getTransitionKind());

            // No error expected before cancellation — proves the wrapping
            // observer forwarded only data, not a synthetic error.
            assertNull(errorRef.get(), "no error expected before cancellation");

            // Cancellation must propagate to the underlying gRPC call.
            subscription.cancel();
            assertTrue(serverCancelled.await(5, TimeUnit.SECONDS),
                    "server should observe RPC cancellation after subscription.cancel()");
        }
    }

    @Test
    void commandFailureKeepsRawReply() throws Exception {
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
                        .setProtocolStatus(ProtocolStatus.newBuilder()
                                .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
                                .setMessage("MXAccess rejected the write."))
                        .setHresult(-2147220992)
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "failure-session");

            MxAccessException error = assertThrows(
                    MxAccessException.class,
                    () -> session.write(1, 2, MxValues.int32Value(123), 0));

            assertNotNull(error.reply());
            assertEquals(-2147220992, error.reply().getHresult());
        }
    }

    @Test
    void writeArrayElementsBuildsSparseArrayWriteCommand() throws Exception {
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                commandRequest.set(request);
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok())
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "sparse-session");

            // Supply indices out of order to prove deterministic ascending iteration.
            Map<Integer, MxValue> elements = Map.of(
                    3, MxValues.int32Value(99),
                    1, MxValues.int32Value(7));

            session.writeArrayElements(12, 34, MxDataType.MX_DATA_TYPE_INTEGER, 5, elements, 56);

            MxCommandRequest request = commandRequest.get();
            assertNotNull(request);
            assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE, request.getCommand().getKind());
            assertEquals(12, request.getCommand().getWrite().getServerHandle());
            assertEquals(34, request.getCommand().getWrite().getItemHandle());
            assertEquals(56, request.getCommand().getWrite().getUserId());

            MxValue written = request.getCommand().getWrite().getValue();
            assertEquals(MxValue.KindCase.SPARSE_ARRAY_VALUE, written.getKindCase());
            assertEquals(5, written.getSparseArrayValue().getTotalLength());
            assertEquals(
                    MxDataType.MX_DATA_TYPE_INTEGER,
                    written.getSparseArrayValue().getElementDataType());

            List<MxSparseElement> sparse = written.getSparseArrayValue().getElementsList();
            assertEquals(2, sparse.size());
            // Ascending index order is guaranteed by the helper.
            assertEquals(1, sparse.get(0).getIndex());
            assertEquals(7, sparse.get(0).getValue().getInt32Value());
            assertEquals(3, sparse.get(1).getIndex());
            assertEquals(99, sparse.get(1).getValue().getInt32Value());
        }
    }

    @Test
    void writeArrayElementsRejectsNonPositiveTotalLength() throws Exception {
        // Client.Java-051: negative/zero totalLength silently sign-extends to a
        // large uint32 on the wire; the client must reject it with
        // IllegalArgumentException before building the proto message (before any
        // network call is issued).
        try (InProcessGateway gateway = InProcessGateway.start(
                new TestGatewayService() {}, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "guard-session");

            assertThrows(
                    IllegalArgumentException.class,
                    () -> session.writeArrayElements(
                            1, 2, MxDataType.MX_DATA_TYPE_INTEGER, -1, Map.of(), 0),
                    "negative totalLength must throw");

            assertThrows(
                    IllegalArgumentException.class,
                    () -> session.writeArrayElements(
                            1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 0, Map.of(), 0),
                    "zero totalLength must throw");
        }
    }

    @Test
    void writeArrayElementsRejectsOutOfRangeIndex() throws Exception {
        // Client.Java-051: a negative index silently sign-extends to a large
        // uint32 on the wire; an index >= totalLength exceeds the declared
        // array bounds. Both must be caught before the network call.
        try (InProcessGateway gateway = InProcessGateway.start(
                new TestGatewayService() {}, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "guard-session");

            assertThrows(
                    IllegalArgumentException.class,
                    () -> session.writeArrayElements(
                            1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
                            Map.of(-1, MxValues.int32Value(7)), 0),
                    "negative index must throw");

            assertThrows(
                    IllegalArgumentException.class,
                    () -> session.writeArrayElements(
                            1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
                            Map.of(5, MxValues.int32Value(7)), 0),
                    "index equal to totalLength must throw");

            assertThrows(
                    IllegalArgumentException.class,
                    () -> session.writeArrayElements(
                            1, 2, MxDataType.MX_DATA_TYPE_INTEGER, 5,
                            Map.of(10, MxValues.int32Value(7)), 0),
                    "index above totalLength must throw");
        }
    }

    @Test
    void streamEventsSurfacesReplayGapSentinelAsTypedItem() throws Exception {
        // CLI-15: a resumed stream whose cursor predates the retained window
        // begins with the gateway's replay-gap sentinel. It must surface as a
        // distinct typed item, and normal events must be unaffected.
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
                responseObserver.onNext(MxEvent.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setReplayGap(ReplayGap.newBuilder()
                                .setRequestedAfterSequence(5)
                                .setOldestAvailableSequence(9))
                        .build());
                responseObserver.onNext(MxEvent.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
                        .setWorkerSequence(9)
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5));
                MxEventStream events =
                        MxGatewaySession.forSessionId(client, "resume-session").streamEventsAfter(5)) {
            assertTrue(events.hasNext());
            MxEventStreamItem gap = events.nextItem();
            assertTrue(gap.isReplayGap(), "head sentinel must classify as a replay gap");
            assertEquals(5, gap.replayGap().getRequestedAfterSequence());
            assertEquals(9, gap.replayGap().getOldestAvailableSequence());
            // Sentinel carries no MXAccess payload.
            assertEquals(MxEventFamily.MX_EVENT_FAMILY_UNSPECIFIED, gap.event().getFamily());

            assertTrue(events.hasNext());
            MxEventStreamItem normal = events.nextItem();
            assertFalse(normal.isReplayGap(), "normal event must not classify as a replay gap");
            assertEquals(9, normal.event().getWorkerSequence());
            assertEquals(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE, normal.event().getFamily());
        }
    }

    @Test
    void adviseSupervisoryBuildsSupervisoryCommand() throws Exception {
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        TestGatewayService service = okInvokeService(commandRequest);

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "advise-super-session");

            session.adviseSupervisory(12, 34);

            assertEquals(
                    MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
                    commandRequest.get().getCommand().getKind());
            assertEquals(12, commandRequest.get().getCommand().getAdviseSupervisory().getServerHandle());
            assertEquals(34, commandRequest.get().getCommand().getAdviseSupervisory().getItemHandle());
        }
    }

    @Test
    void writeSecuredSurfacesNativeMxAccessFailure() throws Exception {
        // CLI-04 parity: WriteSecured before authenticate/advise fails natively;
        // the client surfaces the failure rather than papering over it.
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ProtocolStatus.newBuilder()
                                .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
                                .setMessage("WriteSecured rejected: user not authenticated."))
                        .setHresult(-2147220992)
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-session");

            MxAccessException error = assertThrows(
                    MxAccessException.class,
                    () -> session.writeSecured(1, 2, 100, 100, MxValues.stringValue("secret-payload")));

            assertEquals(-2147220992, error.reply().getHresult());
            // The credential-bearing value lives only in the request, so it must
            // not appear in any surfaced error text.
            assertFalse(String.valueOf(error.getMessage()).contains("secret-payload"));
        }
    }

    @Test
    void writeSecuredScriptedSuccessSendsSecuredCommand() throws Exception {
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        TestGatewayService service = okInvokeService(commandRequest);

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-ok-session");

            session.writeSecured(7, 8, 100, 200, MxValues.int32Value(42));

            var command = commandRequest.get().getCommand();
            assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED, command.getKind());
            assertEquals(7, command.getWriteSecured().getServerHandle());
            assertEquals(8, command.getWriteSecured().getItemHandle());
            assertEquals(100, command.getWriteSecured().getCurrentUserId());
            assertEquals(200, command.getWriteSecured().getVerifierUserId());
            assertEquals(42, command.getWriteSecured().getValue().getInt32Value());
        }
    }

    @Test
    void authenticateUserReturnsUserIdAndForwardsCredential() throws Exception {
        AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                commandRequest.set(request);
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok())
                        .setAuthenticateUser(AuthenticateUserReply.newBuilder().setUserId(4242))
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-session");

            int userId = session.authenticateUser(3, "operator", "super-secret-pw");

            assertEquals(4242, userId);
            // The credential is forwarded in the request only.
            assertEquals(
                    "super-secret-pw",
                    commandRequest.get().getCommand().getAuthenticateUser().getVerifyUserPassword());
        }
    }

    @Test
    void authenticateUserFailureKeepsCredentialOutOfSurfacedError() throws Exception {
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ProtocolStatus.newBuilder()
                                .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
                                .setMessage("AuthenticateUser rejected the credential."))
                        .setHresult(-2147220992)
                        .build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-fail-session");

            MxAccessException error = assertThrows(
                    MxAccessException.class,
                    () -> session.authenticateUser(3, "operator", "super-secret-pw"));

            // The password must never reach the exception message or toString().
            assertFalse(String.valueOf(error.getMessage()).contains("super-secret-pw"));
            assertFalse(error.toString().contains("super-secret-pw"));
        }
    }

    @Test
    void suspendAndActivateReturnStatusProxy() throws Exception {
        TestGatewayService service = new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                MxCommandReply.Builder reply = MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok());
                // `category` is the authoritative success indicator, so the fake
                // must set it — a bare non-zero `success` is not a success.
                var okStatus = mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
                        .setSuccess(1)
                        .setCategory(mxaccess_gateway.v1.MxaccessGateway.MxStatusCategory.MX_STATUS_CATEGORY_OK);
                if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_SUSPEND) {
                    reply.setSuspend(mxaccess_gateway.v1.MxaccessGateway.SuspendReply.newBuilder()
                            .setStatus(okStatus));
                } else if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ACTIVATE) {
                    reply.setActivate(mxaccess_gateway.v1.MxaccessGateway.ActivateReply.newBuilder()
                            .setStatus(okStatus));
                }
                responseObserver.onNext(reply.build());
                responseObserver.onCompleted();
            }
        };

        try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
                MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
            MxGatewaySession session = MxGatewaySession.forSessionId(client, "suspend-session");

            assertTrue(MxStatuses.succeeded(session.suspend(1, 2)));
            assertTrue(MxStatuses.succeeded(session.activate(1, 2)));
        }
    }

    private static TestGatewayService okInvokeService(AtomicReference<MxCommandRequest> commandRequest) {
        return new TestGatewayService() {
            @Override
            public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
                commandRequest.set(request);
                responseObserver.onNext(MxCommandReply.newBuilder()
                        .setSessionId(request.getSessionId())
                        .setKind(request.getCommand().getKind())
                        .setProtocolStatus(ok())
                        .build());
                responseObserver.onCompleted();
            }
        };
    }

    private static ProtocolStatus ok() {
        return ProtocolStatus.newBuilder()
                .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
                .build();
    }

    private abstract static class TestGatewayService extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
        @Override
        public void openSession(OpenSessionRequest request, StreamObserver<OpenSessionReply> responseObserver) {
            responseObserver.onNext(OpenSessionReply.newBuilder()
                    .setSessionId("session-java")
                    .setProtocolStatus(ok())
                    .build());
            responseObserver.onCompleted();
        }

        @Override
        public void closeSession(CloseSessionRequest request, StreamObserver<CloseSessionReply> responseObserver) {
            responseObserver.onNext(CloseSessionReply.newBuilder()
                    .setSessionId(request.getSessionId())
                    .setFinalState(SessionState.SESSION_STATE_CLOSED)
                    .setProtocolStatus(ok())
                    .build());
            responseObserver.onCompleted();
        }
    }

    private record InProcessGateway(Server server, ManagedChannel channel) implements AutoCloseable {
        static InProcessGateway start(
                MxAccessGatewayGrpc.MxAccessGatewayImplBase service, AtomicReference<String> authorization)
                throws Exception {
            String serverName = "mxgw-java-" + UUID.randomUUID();
            ServerInterceptor interceptor = new ServerInterceptor() {
                @Override
                public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
                        ServerCall<ReqT, RespT> call,
                        Metadata headers,
                        ServerCallHandler<ReqT, RespT> next) {
                    authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
                    return next.startCall(call, headers);
                }
            };
            Server server = InProcessServerBuilder.forName(serverName)
                    .directExecutor()
                    .addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
                    .build()
                    .start();
            ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
                    .directExecutor()
                    .build();
            return new InProcessGateway(server, channel);
        }

        MxGatewayClient client(String apiKey, Duration callTimeout) {
            return new MxGatewayClient(
                    channel,
                    MxGatewayClientOptions.builder()
                            .endpoint("in-process")
                            .apiKey(apiKey)
                            .plaintext(true)
                            .callTimeout(callTimeout)
                            .build());
        }

        @Override
        public void close() {
            channel.shutdownNow();
            server.shutdownNow();
        }
    }
}
