//! Typed handle around an opened gateway session.
//!
//! [`Session`] wraps an `OpenSession` reply (just the session id) plus a
//! cloned [`GatewayClient`] and offers Rust-shaped methods for the
//! command surface that the worker exposes — `Register`, `AddItem`,
//! bulk subscribe variants, `Write`/`Write2`, and the event stream.
//!
//! Bulk commands enforce a 1000-item cap before contacting the worker, in
//! line with the gateway's documented `MAX_BULK_ITEMS`.

use std::sync::atomic::{AtomicU64, Ordering};

use crate::client::{EventStream, GatewayClient};
use crate::error::{ensure_protocol_success, Error, MxAccessError};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
    ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
    AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
    AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
    MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
    MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
    RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
    StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
    UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
    Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
    WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
    WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
use crate::value::{MxStatus, MxValue, MxValueProjection};

const MAX_BULK_ITEMS: usize = 1_000;

/// Process-wide monotonic sequence used by [`next_correlation_id`].
static CORRELATION_SEQUENCE: AtomicU64 = AtomicU64::new(1);

/// Build a per-call correlation id that embeds the supplied `label`.
///
/// The returned token is opaque and guaranteed to be unique within the
/// current process: every call increments a process-wide atomic counter,
/// so concurrent CLI smokes and library callers on the same machine produce
/// distinct ids that gateway logs can tell apart. The token carries no
/// embedded secret beyond `label`.
///
/// The exact textual format (currently `rust-client-{label}-{N}`) is *not*
/// part of the public contract — callers must not parse it. The crate root
/// re-exports this helper as
/// [`zb_mom_ww_mxgateway_client::next_correlation_id`] so out-of-tree
/// consumers can build correlation ids without referencing the `session`
/// module path.
pub fn next_correlation_id(label: &str) -> String {
    let sequence = CORRELATION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
    format!("rust-client-{label}-{sequence}")
}

/// Handle to an opened gateway session.
///
/// `Session` carries the gateway-issued session id and a cloned
/// [`GatewayClient`]. All methods are async and stateless on the client
/// side: the gateway tracks per-session worker state. Drop the handle and
/// call [`Session::close`] to release the worker; the gateway also reaps
/// orphaned sessions on its own schedule.
#[derive(Clone)]
pub struct Session {
    id: String,
    client: GatewayClient,
}

impl Session {
    pub(crate) fn new(id: impl Into<String>, client: GatewayClient) -> Self {
        Self {
            id: id.into(),
            client,
        }
    }

    /// Borrow the gateway-assigned session id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Convenience constructor that issues `OpenSession` with the supplied
    /// client session name and returns the resulting [`Session`].
    ///
    /// # Errors
    ///
    /// Propagates errors from
    /// [`GatewayClient::open_session`](crate::client::GatewayClient::open_session).
    pub async fn open(client: GatewayClient, client_session_name: &str) -> Result<Self, Error> {
        client
            .open_session(OpenSessionRequest {
                client_session_name: client_session_name.to_owned(),
                ..OpenSessionRequest::default()
            })
            .await
    }

    /// Issue `CloseSession` against this session id.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ProtocolStatus`] if the gateway returns a non-OK
    /// envelope and any transport/status errors propagated by tonic.
    pub async fn close(&self) -> Result<(), Error> {
        let reply = self
            .client
            .close_session_raw(CloseSessionRequest {
                session_id: self.id.clone(),
                client_correlation_id: next_correlation_id("close-session"),
            })
            .await?;
        ensure_protocol_success("close session", reply.protocol_status.as_ref())?;
        Ok(())
    }

    /// Run MXAccess `Register` and return the assigned `ServerHandle`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] if the worker reports a non-OK status,
    /// plus transport/status errors from tonic.
    pub async fn register(&self, client_name: &str) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::Register,
                Payload::Register(RegisterCommand {
                    client_name: client_name.to_owned(),
                }),
            )
            .await?;

        register_server_handle(&reply)
    }

    /// Run MXAccess `Unregister` to release the given `ServerHandle` and the
    /// items advised under it. Mirrors [`Session::register`]; the worker
    /// returns no payload, so the call resolves to `()` on success.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] if the worker reports a non-OK protocol
    /// status and [`Error::MxAccess`] if MXAccess itself rejects the
    /// unregister (negative `hresult` / non-success status), plus the usual
    /// transport/status errors.
    pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::Unregister,
            Payload::Unregister(UnregisterCommand { server_handle }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `AddItem` against `server_handle` and return the
    /// assigned `ItemHandle`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] when MXAccess rejects the item
    /// definition, plus transport/status errors from tonic.
    pub async fn add_item(&self, server_handle: i32, item_definition: &str) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::AddItem,
                Payload::AddItem(AddItemCommand {
                    server_handle,
                    item_definition: item_definition.to_owned(),
                }),
            )
            .await?;

        add_item_handle(&reply)
    }

    /// Run MXAccess `AddItem2` (item with a caller-supplied context string)
    /// and return the assigned `ItemHandle`.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item`].
    pub async fn add_item2(
        &self,
        server_handle: i32,
        item_definition: &str,
        item_context: &str,
    ) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::AddItem2,
                Payload::AddItem2(AddItem2Command {
                    server_handle,
                    item_definition: item_definition.to_owned(),
                    item_context: item_context.to_owned(),
                }),
            )
            .await?;

        add_item2_handle(&reply)
    }

    /// Run MXAccess `RemoveItem` for the given handle pair.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] on a non-OK worker status, plus the
    /// usual transport/status errors.
    pub async fn remove_item(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::RemoveItem,
            Payload::RemoveItem(RemoveItemCommand {
                server_handle,
                item_handle,
            }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `Advise` to start receiving change notifications for
    /// the given item.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] when the worker reports a non-OK status.
    pub async fn advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::Advise,
            Payload::Advise(AdviseCommand {
                server_handle,
                item_handle,
            }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `UnAdvise` to stop change notifications for the given
    /// item.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] when the worker reports a non-OK status.
    pub async fn un_advise(&self, server_handle: i32, item_handle: i32) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::UnAdvise,
            Payload::UnAdvise(UnAdviseCommand {
                server_handle,
                item_handle,
            }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
    /// notifications for the given item. Mirrors [`Session::advise`]; the
    /// worker returns no payload, so the call resolves to `()` on success.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for a non-OK protocol status and
    /// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
    /// non-success status, plus the usual transport/status errors.
    pub async fn advise_supervisory(
        &self,
        server_handle: i32,
        item_handle: i32,
    ) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::AdviseSupervisory,
            Payload::AdviseSupervisory(AdviseSupervisoryCommand {
                server_handle,
                item_handle,
            }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `Suspend` on the given item and return the native
    /// `MXSTATUS_PROXY` the worker reports for the operation.
    ///
    /// A top-level MXAccess failure (negative `hresult` or a non-success
    /// top-level status) still surfaces as [`Error::MxAccess`] via the shared
    /// reply validation; the returned [`MxStatus`] is the per-operation status
    /// carried in the `SuspendReply` payload.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for a non-OK protocol status,
    /// [`Error::MxAccess`] on an MXAccess-level failure, and
    /// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
    /// plus the usual transport/status errors.
    pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
        let reply = self
            .invoke(
                MxCommandKind::Suspend,
                Payload::Suspend(SuspendCommand {
                    server_handle,
                    item_handle,
                }),
            )
            .await?;

        suspend_status(reply)
    }

    /// Run MXAccess `Activate` on the given item and return the native
    /// `MXSTATUS_PROXY` the worker reports for the operation. See
    /// [`Session::suspend`] for the status/error contract.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::suspend`] (with the `Activate` payload).
    pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result<MxStatus, Error> {
        let reply = self
            .invoke(
                MxCommandKind::Activate,
                Payload::Activate(ActivateCommand {
                    server_handle,
                    item_handle,
                }),
            )
            .await?;

        activate_status(reply)
    }

    /// Run MXAccess `AddBufferedItem` against `server_handle` and return the
    /// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
    /// item carries a caller-supplied context string.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
    /// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
    /// lacks the item handle, plus the usual transport/status errors.
    pub async fn add_buffered_item(
        &self,
        server_handle: i32,
        item_definition: &str,
        item_context: &str,
    ) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::AddBufferedItem,
                Payload::AddBufferedItem(AddBufferedItemCommand {
                    server_handle,
                    item_definition: item_definition.to_owned(),
                    item_context: item_context.to_owned(),
                }),
            )
            .await?;

        add_buffered_item_handle(&reply)
    }

    /// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
    /// worker returns no payload, so the call resolves to `()` on success.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
    /// status or an MXAccess-level failure, plus the usual transport/status
    /// errors.
    pub async fn set_buffered_update_interval(
        &self,
        server_handle: i32,
        update_interval_milliseconds: i32,
    ) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::SetBufferedUpdateInterval,
            Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
                server_handle,
                update_interval_milliseconds,
            }),
        )
        .await?;
        Ok(())
    }

    /// Bulk variant of [`Session::add_item`]. Each tag address yields one
    /// `SubscribeResult` in the returned vector.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidArgument`] when the input exceeds the
    /// gateway's 1000-item bulk cap, plus the usual command-level errors.
    pub async fn add_item_bulk(
        &self,
        server_handle: i32,
        tag_addresses: Vec<String>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("tag_addresses", tag_addresses.len())?;
        let reply = self
            .invoke(
                MxCommandKind::AddItemBulk,
                Payload::AddItemBulk(AddItemBulkCommand {
                    server_handle,
                    tag_addresses,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::AddItem)
    }

    /// Bulk variant of [`Session::advise`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn advise_item_bulk(
        &self,
        server_handle: i32,
        item_handles: Vec<i32>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("item_handles", item_handles.len())?;
        let reply = self
            .invoke(
                MxCommandKind::AdviseItemBulk,
                Payload::AdviseItemBulk(AdviseItemBulkCommand {
                    server_handle,
                    item_handles,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::AdviseItem)
    }

    /// Bulk variant of [`Session::remove_item`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn remove_item_bulk(
        &self,
        server_handle: i32,
        item_handles: Vec<i32>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("item_handles", item_handles.len())?;
        let reply = self
            .invoke(
                MxCommandKind::RemoveItemBulk,
                Payload::RemoveItemBulk(RemoveItemBulkCommand {
                    server_handle,
                    item_handles,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::RemoveItem)
    }

    /// Bulk variant of [`Session::un_advise`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn un_advise_item_bulk(
        &self,
        server_handle: i32,
        item_handles: Vec<i32>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("item_handles", item_handles.len())?;
        let reply = self
            .invoke(
                MxCommandKind::UnAdviseItemBulk,
                Payload::UnAdviseItemBulk(UnAdviseItemBulkCommand {
                    server_handle,
                    item_handles,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::UnAdviseItem)
    }

    /// Bulk `Subscribe` (atomic add-and-advise) for a list of tag addresses.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn subscribe_bulk(
        &self,
        server_handle: i32,
        tag_addresses: Vec<String>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("tag_addresses", tag_addresses.len())?;
        let reply = self
            .invoke(
                MxCommandKind::SubscribeBulk,
                Payload::SubscribeBulk(SubscribeBulkCommand {
                    server_handle,
                    tag_addresses,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::Subscribe)
    }

    /// Bulk `Unsubscribe` (atomic un-advise-and-remove) for a list of
    /// item handles.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn unsubscribe_bulk(
        &self,
        server_handle: i32,
        item_handles: Vec<i32>,
    ) -> Result<Vec<SubscribeResult>, Error> {
        ensure_bulk_size("item_handles", item_handles.len())?;
        let reply = self
            .invoke(
                MxCommandKind::UnsubscribeBulk,
                Payload::UnsubscribeBulk(UnsubscribeBulkCommand {
                    server_handle,
                    item_handles,
                }),
            )
            .await?;

        bulk_results(reply, BulkReplyKind::Unsubscribe)
    }

    /// Bulk `Read` — snapshot the current value for each requested tag.
    ///
    /// MXAccess COM has no synchronous `Read`; the worker satisfies this by
    /// returning the most recent cached `OnDataChange` value when the tag is
    /// already advised (`was_cached = true`), or by taking a full AddItem +
    /// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle otherwise.
    /// `timeout_ms == 0` lets the worker pick its default (1000 ms).
    /// Per-tag failures appear as `BulkReadResult` entries with
    /// `was_successful = false`; the call never errors on per-tag failure.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`].
    pub async fn read_bulk<S: AsRef<str>>(
        &self,
        server_handle: i32,
        tag_addresses: &[S],
        timeout_ms: u32,
    ) -> Result<Vec<BulkReadResult>, Error> {
        ensure_bulk_size("tag_addresses", tag_addresses.len())?;
        let reply = self
            .invoke(
                MxCommandKind::ReadBulk,
                Payload::ReadBulk(ReadBulkCommand {
                    server_handle,
                    tag_addresses: tag_addresses
                        .iter()
                        .map(|tag| tag.as_ref().to_owned())
                        .collect(),
                    timeout_ms,
                }),
            )
            .await?;

        match reply.payload {
            Some(mx_command_reply::Payload::ReadBulk(reply)) => Ok(reply.results),
            _ => Err(Error::MalformedReply {
                detail: "read_bulk reply did not carry a ReadBulk payload".to_owned(),
            }),
        }
    }

    /// Bulk `Write` (sequential MXAccess Write per entry, on the worker's STA).
    ///
    /// Per-entry MXAccess failures are reported as `BulkWriteResult` entries
    /// with `was_successful = false`; the call never errors on per-entry
    /// failure. Protocol-level failures still surface as [`Error::Command`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::add_item_bulk`], plus the usual
    /// transport/status errors.
    pub async fn write_bulk(
        &self,
        server_handle: i32,
        entries: Vec<WriteBulkEntry>,
    ) -> Result<Vec<BulkWriteResult>, Error> {
        ensure_bulk_size("entries", entries.len())?;
        let reply = self
            .invoke(
                MxCommandKind::WriteBulk,
                Payload::WriteBulk(WriteBulkCommand {
                    server_handle,
                    entries,
                }),
            )
            .await?;

        bulk_write_results(reply, BulkWriteReplyKind::Write)
    }

    /// Bulk `Write2` (timestamped) — see [`Session::write_bulk`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::write_bulk`].
    pub async fn write2_bulk(
        &self,
        server_handle: i32,
        entries: Vec<Write2BulkEntry>,
    ) -> Result<Vec<BulkWriteResult>, Error> {
        ensure_bulk_size("entries", entries.len())?;
        let reply = self
            .invoke(
                MxCommandKind::Write2Bulk,
                Payload::Write2Bulk(Write2BulkCommand {
                    server_handle,
                    entries,
                }),
            )
            .await?;

        bulk_write_results(reply, BulkWriteReplyKind::Write2)
    }

    /// Bulk `WriteSecured` — credential-sensitive values follow the same
    /// redaction contract as the single-item `write_secured` path.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::write_bulk`].
    pub async fn write_secured_bulk(
        &self,
        server_handle: i32,
        entries: Vec<WriteSecuredBulkEntry>,
    ) -> Result<Vec<BulkWriteResult>, Error> {
        ensure_bulk_size("entries", entries.len())?;
        let reply = self
            .invoke(
                MxCommandKind::WriteSecuredBulk,
                Payload::WriteSecuredBulk(WriteSecuredBulkCommand {
                    server_handle,
                    entries,
                }),
            )
            .await?;

        bulk_write_results(reply, BulkWriteReplyKind::WriteSecured)
    }

    /// Bulk `WriteSecured2` (timestamped) — see [`Session::write_secured_bulk`].
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::write_bulk`].
    pub async fn write_secured2_bulk(
        &self,
        server_handle: i32,
        entries: Vec<WriteSecured2BulkEntry>,
    ) -> Result<Vec<BulkWriteResult>, Error> {
        ensure_bulk_size("entries", entries.len())?;
        let reply = self
            .invoke(
                MxCommandKind::WriteSecured2Bulk,
                Payload::WriteSecured2Bulk(WriteSecured2BulkCommand {
                    server_handle,
                    entries,
                }),
            )
            .await?;

        bulk_write_results(reply, BulkWriteReplyKind::WriteSecured2)
    }

    /// Run MXAccess `Write` (single-value, no caller-supplied timestamp).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for non-OK worker statuses, plus the
    /// usual transport/status errors.
    pub async fn write(
        &self,
        server_handle: i32,
        item_handle: i32,
        value: MxValue,
        user_id: i32,
    ) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::Write,
            Payload::Write(WriteCommand {
                server_handle,
                item_handle,
                value: Some(value.into_proto()),
                user_id,
            }),
        )
        .await?;
        Ok(())
    }

    /// Write a sparse, default-filled array: only the given elements
    /// (index → scalar value) are set; every unmentioned index up to
    /// `total_length` is written as the element type's default (a reset,
    /// **not** a preserve). The gateway expands the sparse representation into
    /// a whole-array write before forwarding to the worker.
    ///
    /// This is a convenience wrapper around [`Session::write`] that builds the
    /// `MxSparseArray` wire value for you. Call [`Session::write`] directly
    /// if you need to pass a pre-built [`MxValue`] carrying a full
    /// `MxArray`.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidArgument`] (propagated from the gateway) if
    /// `total_length` is zero, exceeds the gateway's maximum array length, or
    /// any element index is out of range. Returns [`Error::Command`] for
    /// non-OK worker statuses, plus the usual transport/status errors.
    pub async fn write_array_elements(
        &self,
        server_handle: i32,
        item_handle: i32,
        element_data_type: MxDataType,
        total_length: u32,
        elements: impl IntoIterator<Item = (u32, MxValue)>,
        user_id: i32,
    ) -> Result<(), Error> {
        use crate::generated::mxaccess_gateway::v1::mx_value::Kind;

        let sparse_elements: Vec<MxSparseElement> = elements
            .into_iter()
            .map(|(index, value)| MxSparseElement {
                index,
                value: Some(value.into_proto()),
            })
            .collect();

        let sparse_value = ProtoMxValue {
            kind: Some(Kind::SparseArrayValue(MxSparseArray {
                element_data_type: element_data_type as i32,
                total_length,
                elements: sparse_elements,
            })),
            ..ProtoMxValue::default()
        };

        self.write(
            server_handle,
            item_handle,
            MxValue::from_proto(sparse_value),
            user_id,
        )
        .await
    }

    /// Run MXAccess `Write2` (single-value with caller-supplied timestamp).
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::write`].
    pub async fn write2(
        &self,
        server_handle: i32,
        item_handle: i32,
        value: MxValue,
        timestamp_value: MxValue,
        user_id: i32,
    ) -> Result<(), Error> {
        self.invoke(
            MxCommandKind::Write2,
            Payload::Write2(Write2Command {
                server_handle,
                item_handle,
                value: Some(value.into_proto()),
                timestamp_value: Some(timestamp_value.into_proto()),
                user_id,
            }),
        )
        .await?;
        Ok(())
    }

    /// Run MXAccess `WriteSecured` (single credential-verified write, no
    /// caller-supplied timestamp).
    ///
    /// **MXAccess parity:** `WriteSecured` failing before a prior
    /// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
    /// before a value-bearing body, is the native contract, not a client bug —
    /// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
    /// **not** smoothed over. The `value` is credential-sensitive: it is placed
    /// only in the wire command and is never logged or embedded in an error
    /// message.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for a non-OK protocol status,
    /// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
    /// usual transport/status errors.
    pub async fn write_secured(
        &self,
        server_handle: i32,
        item_handle: i32,
        current_user_id: i32,
        verifier_user_id: i32,
        value: MxValue,
    ) -> Result<(), Error> {
        let secrets = string_secret(&value);
        self.invoke(
            MxCommandKind::WriteSecured,
            Payload::WriteSecured(WriteSecuredCommand {
                server_handle,
                item_handle,
                current_user_id,
                verifier_user_id,
                value: Some(value.into_proto()),
            }),
        )
        .await
        .map_err(|error| attach_secrets(error, secrets))?;
        Ok(())
    }

    /// Run MXAccess `WriteSecured2` (credential-verified write with a
    /// caller-supplied timestamp). See [`Session::write_secured`] for the
    /// parity and credential-handling contract.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::write_secured`].
    pub async fn write_secured2(
        &self,
        server_handle: i32,
        item_handle: i32,
        current_user_id: i32,
        verifier_user_id: i32,
        value: MxValue,
        timestamp_value: MxValue,
    ) -> Result<(), Error> {
        let secrets = string_secret(&value);
        self.invoke(
            MxCommandKind::WriteSecured2,
            Payload::WriteSecured2(WriteSecured2Command {
                server_handle,
                item_handle,
                current_user_id,
                verifier_user_id,
                value: Some(value.into_proto()),
                timestamp_value: Some(timestamp_value.into_proto()),
            }),
        )
        .await
        .map_err(|error| attach_secrets(error, secrets))?;
        Ok(())
    }

    /// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
    /// id.
    ///
    /// **Credential handling:** `verify_user_password` is a raw MXAccess
    /// credential. It is placed only in the wire command; this helper never
    /// logs it and never embeds it in an [`Error`] — the only error text that
    /// can surface comes from `tonic::Status` messages and the reply's
    /// diagnostic fields, both of which are scrubbed by the client's
    /// credential-redaction seam (see [`crate::error`]). The reply itself
    /// carries no echo of the credential.
    ///
    /// **MXAccess parity:** a failed authentication is a native outcome, not a
    /// client error to paper over — it surfaces as [`Error::MxAccess`]
    /// (negative `hresult`) with the credential absent from the message.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for a non-OK protocol status,
    /// [`Error::MxAccess`] when MXAccess rejects the credential,
    /// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
    /// usual transport/status errors.
    pub async fn authenticate_user(
        &self,
        server_handle: i32,
        verify_user: &str,
        verify_user_password: &str,
    ) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::AuthenticateUser,
                Payload::AuthenticateUser(AuthenticateUserCommand {
                    server_handle,
                    verify_user: verify_user.to_owned(),
                    verify_user_password: verify_user_password.to_owned(),
                }),
            )
            .await
            .map_err(|error| attach_secrets(error, vec![verify_user_password.to_owned()]))?;

        authenticate_user_id(&reply)
    }

    /// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
    /// its MXAccess user id.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
    /// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
    /// reply lacks the user id, plus the usual transport/status errors.
    pub async fn archestra_user_to_id(
        &self,
        server_handle: i32,
        user_id_guid: &str,
    ) -> Result<i32, Error> {
        let reply = self
            .invoke(
                MxCommandKind::ArchestraUserToId,
                Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
                    server_handle,
                    user_id_guid: user_id_guid.to_owned(),
                }),
            )
            .await?;

        archestra_user_id(&reply)
    }

    /// Open the per-session event stream from the beginning.
    ///
    /// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
    /// values — normal MXAccess events as
    /// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
    /// beginning never produces a
    /// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
    /// only appears on a resumed stream (see [`Session::events_after`]).
    ///
    /// # Errors
    ///
    /// Returns the `tonic::Status` mapped through [`Error::from`] when the
    /// gateway rejects the subscription.
    pub async fn events(&self) -> Result<EventStream, Error> {
        self.events_after(0).await
    }

    /// Open the per-session event stream, requesting only events whose
    /// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
    /// to receive every buffered event.
    ///
    /// If `after_worker_sequence` predates the oldest event still retained in
    /// the gateway's replay ring, the stream opens with a single
    /// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
    /// history was evicted and cannot be replayed, so the caller must discard
    /// any local state and re-snapshot. To resume without provoking another
    /// gap, call this method again with
    /// `gap.oldest_available_sequence - 1`. See
    /// [`EventItem`](crate::EventItem) for the full contract.
    ///
    /// # Errors
    ///
    /// Same conditions as [`Session::events`].
    pub async fn events_after(&self, after_worker_sequence: u64) -> Result<EventStream, Error> {
        self.client
            .stream_events(StreamEventsRequest {
                session_id: self.id.clone(),
                after_worker_sequence,
            })
            .await
    }

    /// Issue a raw `Invoke` for an arbitrary command, without filtering on
    /// the protocol status. Useful when callers need the full reply for
    /// commands not yet wrapped by `Session`.
    ///
    /// # Errors
    ///
    /// Returns the `tonic::Status` mapped through [`Error::from`].
    pub async fn invoke_raw(
        &self,
        kind: MxCommandKind,
        payload: Payload,
    ) -> Result<MxCommandReply, Error> {
        self.client
            .invoke_raw(self.command_request(kind, payload))
            .await
    }

    /// Issue an `Invoke` for an arbitrary command and surface a non-OK
    /// reply as [`Error::Command`].
    ///
    /// # Errors
    ///
    /// Returns [`Error::Command`] for non-OK worker statuses plus any
    /// errors propagated by [`invoke_raw`](Self::invoke_raw).
    pub async fn invoke(
        &self,
        kind: MxCommandKind,
        payload: Payload,
    ) -> Result<MxCommandReply, Error> {
        self.client
            .invoke(self.command_request(kind, payload))
            .await
    }

    fn command_request(&self, kind: MxCommandKind, payload: Payload) -> MxCommandRequest {
        MxCommandRequest {
            session_id: self.id.clone(),
            client_correlation_id: next_correlation_id(kind.as_str_name()),
            command: Some(MxCommand {
                kind: kind as i32,
                payload: Some(payload),
            }),
        }
    }
}

fn ensure_bulk_size(name: &'static str, len: usize) -> Result<(), Error> {
    if len > MAX_BULK_ITEMS {
        Err(Error::InvalidArgument {
            name: name.to_owned(),
            detail: format!("bulk commands are limited to {MAX_BULK_ITEMS} item(s)"),
        })
    } else {
        Ok(())
    }
}

fn register_server_handle(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::Register(register)) => Ok(register.server_handle),
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail: "register reply lacked a server_handle payload or int32 return_value"
                    .to_owned(),
            }),
    }
}

fn add_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::AddItem(add_item)) => Ok(add_item.item_handle),
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail: "add_item reply lacked an item_handle payload or int32 return_value"
                    .to_owned(),
            }),
    }
}

fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::AddItem2(add_item)) => Ok(add_item.item_handle),
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail: "add_item2 reply lacked an item_handle payload or int32 return_value"
                    .to_owned(),
            }),
    }
}

fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
            Ok(add_buffered.item_handle)
        }
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail:
                    "add_buffered_item reply lacked an item_handle payload or int32 return_value"
                        .to_owned(),
            }),
    }
}

fn authenticate_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail:
                    "authenticate_user reply lacked an AuthenticateUser payload or int32 return_value"
                        .to_owned(),
            }),
    }
}

fn archestra_user_id(reply: &MxCommandReply) -> Result<i32, Error> {
    match reply.payload.as_ref() {
        Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
        _ => reply
            .return_value
            .as_ref()
            .and_then(int32_reply_value)
            .ok_or_else(|| Error::MalformedReply {
                detail:
                    "archestra_user_to_id reply lacked an ArchestraUserToId payload or int32 return_value"
                        .to_owned(),
            }),
    }
}

/// Extract an exact string secret from a credential-sensitive [`MxValue`] so a
/// failing `WriteSecured`/`WriteSecured2` can scrub it from the surfaced error.
/// Non-string values carry no scrubbable secret and yield an empty vector.
fn string_secret(value: &MxValue) -> Vec<String> {
    match value.projection() {
        MxValueProjection::String(text) if !text.is_empty() => vec![text.clone()],
        _ => Vec::new(),
    }
}

/// Attach caller-supplied exact secrets to an [`Error::MxAccess`] before it
/// propagates. This both scrubs the stored reply's caller-readable string
/// fields (so `reply()`/`into_reply()` cannot recover a credential MXAccess
/// echoed back verbatim) and keeps the secrets on the error as a
/// belt-and-suspenders for `Display`/`Debug`. Any other error variant is
/// returned unchanged.
fn attach_secrets(error: Error, secrets: Vec<String>) -> Error {
    match error {
        Error::MxAccess(boxed) => {
            let mut reply = boxed.into_reply();
            scrub_reply_strings(&mut reply, &secrets);
            Error::MxAccess(Box::new(MxAccessError::new(reply).with_secrets(secrets)))
        }
        other => other,
    }
}

/// Replace every non-empty secret occurrence with `<redacted>` in the reply's
/// caller-readable string fields — `protocol_status.message`,
/// `diagnostic_message`, and each `statuses[i].diagnostic_text`. A caller
/// reading the structured reply back off an [`Error::MxAccess`] would otherwise
/// reintroduce the leak that `Display`/`Debug` already close.
fn scrub_reply_strings(reply: &mut MxCommandReply, secrets: &[String]) {
    for secret in secrets {
        if secret.is_empty() {
            continue;
        }
        if let Some(status) = reply.protocol_status.as_mut() {
            status.message = status.message.replace(secret.as_str(), "<redacted>");
        }
        reply.diagnostic_message = reply
            .diagnostic_message
            .replace(secret.as_str(), "<redacted>");
        for status in &mut reply.statuses {
            status.diagnostic_text = status
                .diagnostic_text
                .replace(secret.as_str(), "<redacted>");
        }
    }
}

fn suspend_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
    match reply.payload {
        Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
            .status
            .map(MxStatus::from_proto)
            .ok_or_else(|| Error::MalformedReply {
                detail: "suspend reply payload lacked a status entry".to_owned(),
            }),
        _ => Err(Error::MalformedReply {
            detail: "suspend reply did not carry a Suspend payload".to_owned(),
        }),
    }
}

fn activate_status(reply: MxCommandReply) -> Result<MxStatus, Error> {
    match reply.payload {
        Some(mx_command_reply::Payload::Activate(activate)) => activate
            .status
            .map(MxStatus::from_proto)
            .ok_or_else(|| Error::MalformedReply {
                detail: "activate reply payload lacked a status entry".to_owned(),
            }),
        _ => Err(Error::MalformedReply {
            detail: "activate reply did not carry an Activate payload".to_owned(),
        }),
    }
}

enum BulkReplyKind {
    AddItem,
    AdviseItem,
    RemoveItem,
    UnAdviseItem,
    Subscribe,
    Unsubscribe,
}

fn bulk_results(reply: MxCommandReply, kind: BulkReplyKind) -> Result<Vec<SubscribeResult>, Error> {
    match (reply.payload, kind) {
        (Some(mx_command_reply::Payload::AddItemBulk(reply)), BulkReplyKind::AddItem) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::AdviseItemBulk(reply)), BulkReplyKind::AdviseItem) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::RemoveItemBulk(reply)), BulkReplyKind::RemoveItem) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::UnAdviseItemBulk(reply)), BulkReplyKind::UnAdviseItem) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::SubscribeBulk(reply)), BulkReplyKind::Subscribe) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::UnsubscribeBulk(reply)), BulkReplyKind::Unsubscribe) => {
            Ok(reply.results)
        }
        _ => Err(Error::MalformedReply {
            detail: "bulk subscribe reply did not carry the expected payload arm".to_owned(),
        }),
    }
}

enum BulkWriteReplyKind {
    Write,
    Write2,
    WriteSecured,
    WriteSecured2,
}

fn bulk_write_results(
    reply: MxCommandReply,
    kind: BulkWriteReplyKind,
) -> Result<Vec<BulkWriteResult>, Error> {
    match (reply.payload, kind) {
        (Some(mx_command_reply::Payload::WriteBulk(reply)), BulkWriteReplyKind::Write) => {
            Ok(reply.results)
        }
        (Some(mx_command_reply::Payload::Write2Bulk(reply)), BulkWriteReplyKind::Write2) => {
            Ok(reply.results)
        }
        (
            Some(mx_command_reply::Payload::WriteSecuredBulk(reply)),
            BulkWriteReplyKind::WriteSecured,
        ) => Ok(reply.results),
        (
            Some(mx_command_reply::Payload::WriteSecured2Bulk(reply)),
            BulkWriteReplyKind::WriteSecured2,
        ) => Ok(reply.results),
        _ => Err(Error::MalformedReply {
            detail: "bulk write reply did not carry the expected payload arm".to_owned(),
        }),
    }
}

fn int32_reply_value(value: &ProtoMxValue) -> Option<i32> {
    match value.kind.as_ref()? {
        crate::generated::mxaccess_gateway::v1::mx_value::Kind::Int32Value(value) => Some(*value),
        _ => None,
    }
}
