//! gRPC server implementing the `WalShipping` service.

use tidaldb::replication::transport::WalSegmentPayload;
use tokio::sync::mpsc;
use tonic::{Request, Response, Status};

use crate::{
    config::GrpcTransportConfig,
    proto::{
        HeartbeatRequest, HeartbeatResponse, ShipSegmentRequest, ShipSegmentResponse,
        StreamRequest,
        wal_shipping_server::{WalShipping, WalShippingServer},
    },
    tls,
};

/// The gRPC service that receives WAL segments from peers.
pub struct WalShippingService {
    inbound_tx: mpsc::Sender<WalSegmentPayload>,
    max_payload_bytes: usize,
}

impl WalShippingService {
    /// Create a new service that forwards received segments to the given channel.
    #[must_use]
    pub const fn new(
        inbound_tx: mpsc::Sender<WalSegmentPayload>,
        max_payload_bytes: usize,
    ) -> Self {
        Self {
            inbound_tx,
            max_payload_bytes,
        }
    }
}

#[tonic::async_trait]
impl WalShipping for WalShippingService {
    async fn ship_segment(
        &self,
        request: Request<ShipSegmentRequest>,
    ) -> Result<Response<ShipSegmentResponse>, Status> {
        let req = request.into_inner();

        // Validate payload size.
        if req.payload.len() > self.max_payload_bytes {
            return Err(Status::resource_exhausted(format!(
                "payload {} bytes exceeds max {}",
                req.payload.len(),
                self.max_payload_bytes
            )));
        }

        // Convert proto to domain type.
        let payload = WalSegmentPayload::try_from(req)
            .map_err(|e| Status::invalid_argument(e.to_string()))?;

        // Try to send on the inbound channel. On full, yield once and retry.
        // If still full, return accepted=false. The WAL is durable on the
        // leader, so the follower will catch up when capacity frees.
        match self.inbound_tx.try_send(payload) {
            Ok(()) => Ok(Response::new(ShipSegmentResponse { accepted: true })),
            Err(mpsc::error::TrySendError::Full(payload)) => {
                tracing::warn!("inbound channel full; yielding and retrying");
                tokio::task::yield_now().await;
                match self.inbound_tx.try_send(payload) {
                    Ok(()) => Ok(Response::new(ShipSegmentResponse { accepted: true })),
                    Err(_) => Ok(Response::new(ShipSegmentResponse { accepted: false })),
                }
            }
            Err(mpsc::error::TrySendError::Closed(_)) => {
                Err(Status::unavailable("receiver shut down"))
            }
        }
    }

    type StreamSegmentsStream =
        tokio_stream::wrappers::ReceiverStream<Result<ShipSegmentRequest, Status>>;

    async fn stream_segments(
        &self,
        _request: Request<StreamRequest>,
    ) -> Result<Response<Self::StreamSegmentsStream>, Status> {
        // DELIBERATELY UNIMPLEMENTED, not a silent stub: this returns an
        // explicit `Status::unimplemented` so a caller that mis-routes to the
        // streaming path fails loudly with an actionable code rather than
        // hanging or silently no-op'ing.
        //
        // Production replication ships segments via the unary `ShipSegment` RPC
        // (the `Transport` trait surface). Server-streaming is only needed for
        // bulk catch-up of a far-behind follower; until that path is built the
        // shipper's per-poll re-send of un-acked seqnos covers catch-up at the
        // cost of more round-trips. Tracked as a known gap in the M8 cluster
        // scope; see docs/specs/14-scale-architecture.md (catch-up replication)
        // and CHANGELOG.md "Known gaps".
        Err(Status::unimplemented(
            "StreamSegments not implemented; use the unary ShipSegment RPC for segment delivery",
        ))
    }

    async fn heartbeat(
        &self,
        request: Request<HeartbeatRequest>,
    ) -> Result<Response<HeartbeatResponse>, Status> {
        // MINIMAL REAL BEHAVIOR, not a silent stub: a heartbeat that reaches
        // this handler proves the gRPC server is up, the listener is accepting,
        // and (under mTLS) the peer's certificate was accepted — i.e. a genuine
        // network-liveness probe. Returning `acknowledged: true` is therefore a
        // truthful answer to "are you reachable", which is exactly what the
        // failure detector needs from the transport layer.
        let req = request.into_inner();

        // Record the peer's claimed identity at debug! so a probe is observable
        // (which shard/region reached us, when) without being silently dropped on
        // the floor. Until the identity is wired into the engine's health table
        // (below) this is the only place the claim is captured, so logging it is
        // load-bearing for "who is heartbeating me" during a cluster incident.
        tracing::debug!(
            shard_id = req.shard_id,
            region_id = req.region_id,
            "received heartbeat",
        );

        // DEFERRED, tracked: forwarding `req.shard_id` / `req.region_id` liveness
        // into the engine's `ControlPlane` health table (so a missed heartbeat
        // demotes the claiming peer, and a spoofed/mismatched identity is flagged)
        // is an additive enrichment, not a contract change — it only widens who
        // observes the claim, captured here. Known gap in the M8 cluster scope;
        // see docs/runbooks/cluster.md (health checks) and CHANGELOG.md "Known
        // gaps". The transport carries no `ControlPlane` handle today; wiring one
        // is the remaining work.
        Ok(Response::new(HeartbeatResponse { acknowledged: true }))
    }
}

/// Start the gRPC server on the given address.
///
/// Returns a `JoinHandle` that resolves when the server stops. Must be called
/// from within a tokio runtime context (it `tokio::spawn`s the serve loop).
///
/// # Errors
///
/// Returns [`GrpcTransportError`](crate::error::GrpcTransportError) if TLS is
/// requested but the server TLS config cannot be built, or if TLS is unset and
/// `--insecure` was not opted into.
pub fn start_server(
    config: &GrpcTransportConfig,
    inbound_tx: mpsc::Sender<WalSegmentPayload>,
) -> Result<
    tokio::task::JoinHandle<Result<(), tonic::transport::Error>>,
    crate::error::GrpcTransportError,
> {
    let service = WalShippingService::new(inbound_tx, config.max_payload_bytes);
    let addr = config.listen_addr;

    let mut server_builder = tonic::transport::Server::builder();

    // Configure TLS if provided.
    if let Some(ref tls_config) = config.tls {
        let tls = tls::server_tls_config(tls_config)?;
        server_builder = server_builder
            .tls_config(tls)
            .map_err(crate::error::GrpcTransportError::TonicTransport)?;
    } else if !config.insecure {
        return Err(crate::error::GrpcTransportError::TlsConfig(
            "TLS not configured and --insecure not set".into(),
        ));
    }

    // Raise tonic's default 4 MiB codec limits to the configured max payload on
    // BOTH directions. The server is the DECODER for inbound ShipSegmentRequest
    // (and the ENCODER for any future server-streaming response). With the
    // default 4 MiB limit a full-size WAL segment (16 MiB by default, up to
    // `max_payload_bytes` = 64 MiB) is rejected inside the codec with a
    // "message too large" status, which the shipper would retry forever as a
    // transient `Closed`. Pin both limits to the same ceiling the application
    // already advertises and validates against (server.rs ship_segment guard,
    // transport.rs send_segment guard) so the wire path and the app agree.
    let max = config.max_payload_bytes;
    let wal_service = WalShippingServer::new(service)
        .max_decoding_message_size(max)
        .max_encoding_message_size(max);

    let handle = tokio::spawn(async move {
        // Observe the serve loop's terminal Result here so a failure AFTER
        // successful startup (e.g. the listener dies, a TLS handshake task
        // panics, the reactor is torn down) is logged at error! rather than
        // silently swallowed when the JoinHandle is dropped or aborted. The
        // Result is still propagated out of the task so callers can also
        // inspect it via the handle (see `GrpcTransport::server_terminated`).
        let result = server_builder.add_service(wal_service).serve(addr).await;
        if let Err(ref e) = result {
            tracing::error!(error = %e, %addr, "gRPC WAL-shipping serve loop terminated with error");
        } else {
            tracing::info!(%addr, "gRPC WAL-shipping serve loop stopped");
        }
        result
    });

    Ok(handle)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // test assertions on known-good fixtures
mod tests {
    use super::*;
    use crate::proto::{ShipSegmentRequest, WalSegmentId};

    fn make_request(payload_len: usize) -> ShipSegmentRequest {
        ShipSegmentRequest {
            id: Some(WalSegmentId {
                region_id: 0,
                shard_id: 0,
                seqno: 1,
            }),
            payload: vec![0xAB; payload_len],
            event_count: 1,
            leader_last_seq: 1,
        }
    }

    /// SUGGESTION (tidal-net): pin the server-side payload boundary semantics so a
    /// future change to one guard cannot silently desync from the client/codec.
    /// The contract (shared with `GrpcTransport::send_segment` in transport.rs and
    /// the codec limits in `start_server`) is **inclusive at `max_payload_bytes`**:
    /// a payload of exactly `max` is ACCEPTED, and `max + 1` is REJECTED
    /// (`len() > max`, not `>=`). This exercises the real `ship_segment` handler at
    /// both sides of the boundary.
    #[test]
    fn ship_segment_payload_boundary_is_inclusive_at_max() {
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        runtime.block_on(async {
            let max = 1024usize;
            let (tx, mut rx) = mpsc::channel(4);
            let service = WalShippingService::new(tx, max);

            // Exactly max: accepted, and forwarded onto the inbound channel.
            let resp = service
                .ship_segment(Request::new(make_request(max)))
                .await
                .expect("a payload of exactly max_payload_bytes must be accepted");
            assert!(
                resp.into_inner().accepted,
                "payload == max must be accepted (inclusive boundary)"
            );
            let forwarded = rx.try_recv().expect("accepted payload reaches the channel");
            assert_eq!(forwarded.bytes.len(), max);

            // max + 1: rejected with resource_exhausted, nothing forwarded.
            let err = service
                .ship_segment(Request::new(make_request(max + 1)))
                .await
                .expect_err("a payload of max + 1 must be rejected");
            assert_eq!(
                err.code(),
                tonic::Code::ResourceExhausted,
                "over-size payload must be rejected as resource_exhausted"
            );
            assert!(
                rx.try_recv().is_err(),
                "a rejected payload must not be forwarded onto the inbound channel"
            );
        });
    }
}
