//! Configuration for the gRPC transport.

use std::{collections::HashMap, net::SocketAddr, path::PathBuf, time::Duration};

use tidaldb::replication::shard::ShardId;

use crate::error::GrpcTransportError;

/// Maximum payload size in bytes (64 MiB).
///
/// **Source of truth:** the engine crate's `InProcessTransport::MAX_PAYLOAD_BYTES`
/// (`tidaldb`, `replication/in_process.rs`). Both transports must reject the same
/// over-size payloads so behavior does not diverge by which transport a deployment
/// uses. That engine constant is currently private (module-local in `in_process.rs`),
/// so it cannot be `use`d here directly; the value is mirrored and pinned by the
/// compile-time cross-check below. If the engine ever exposes it (e.g. as
/// `tidaldb::replication::MAX_PAYLOAD_BYTES`), replace this literal with a re-export
/// of that constant and delete the cross-check.
pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;

/// Compile-time guard pinning [`MAX_PAYLOAD_BYTES`] to the engine's 64 MiB limit.
///
/// This catches accidental drift in the mirrored literal at build time (it is the
/// best available cross-check while the engine constant stays private). If this
/// fails to compile, the two crates' payload limits have diverged: reconcile them,
/// preferring to make the engine's constant the single referenced source of truth.
const _: () = assert!(
    MAX_PAYLOAD_BYTES == 64 * 1024 * 1024,
    "tidal-net MAX_PAYLOAD_BYTES must match the engine's InProcessTransport limit (64 MiB)"
);

/// Configuration for a single [`GrpcTransport`](crate::GrpcTransport) instance.
#[derive(Debug, Clone)]
pub struct GrpcTransportConfig {
    /// This node's shard identity.
    pub local_shard: ShardId,
    /// Address to bind the gRPC server on.
    pub listen_addr: SocketAddr,
    /// Peer shard addresses (`shard_id` -> address).
    pub peers: HashMap<ShardId, SocketAddr>,
    /// TLS configuration. `None` requires `insecure = true`.
    pub tls: Option<TlsConfig>,
    /// Allow plaintext connections (no TLS).
    pub insecure: bool,
    /// Capacity of the inbound segment channel.
    pub channel_capacity: usize,
    /// Maximum payload size in bytes.
    pub max_payload_bytes: usize,
    /// Number of consecutive failures before the circuit breaker opens.
    pub circuit_breaker_threshold: u32,
    /// Duration the circuit breaker stays open before allowing a probe.
    pub circuit_breaker_reset: Duration,
    /// Maximum time to establish a TCP/TLS connection to a peer before the
    /// attempt fails fast (and the failure trips the circuit breaker). Without
    /// this, a peer whose host is up but whose port silently blackholes the
    /// SYN would stall the single-threaded shipper indefinitely.
    pub connect_timeout: Duration,
    /// Maximum time for a single `ship_segment` RPC (connect + send + response)
    /// before it is aborted as a transient failure. Bounds the per-call latency
    /// the shipper can ever incur on a stalled peer.
    pub request_timeout: Duration,
    /// HTTP/2 PING keep-alive interval on the client channel. Idle and active
    /// connections send a PING this often; a peer that stops responding is
    /// detected within `keep_alive_interval + keep_alive_timeout` instead of
    /// hanging on a half-open connection.
    pub keep_alive_interval: Duration,
    /// How long to wait for a keep-alive PING ACK before declaring the
    /// connection dead and tearing it down.
    pub keep_alive_timeout: Duration,
}

impl GrpcTransportConfig {
    /// Reject a config whose numeric invariants would silently break the
    /// transport rather than fail loudly.
    ///
    /// # Errors
    ///
    /// Returns [`GrpcTransportError::Internal`] if any of the following holds.
    /// Each is a misconfiguration that, left unchecked, degrades into a silent
    /// fault the operator cannot see at runtime:
    ///
    /// - `channel_capacity == 0`: [`tokio::sync::mpsc::channel`] panics on a
    ///   zero capacity, so the transport would not even start — surface it as a
    ///   typed error here instead of a panic deep in [`GrpcTransport::new`].
    /// - any timeout is `Duration::ZERO` (`connect_timeout`, `request_timeout`,
    ///   `keep_alive_interval`, `keep_alive_timeout`): a zero deadline aborts
    ///   every attempt instantly, turning the fail-fast design into a transport
    ///   that can never ship a single segment.
    /// - `max_payload_bytes == 0` or `> MAX_PAYLOAD_BYTES`: a zero ceiling
    ///   rejects every segment; a ceiling above the engine's wire limit lets the
    ///   client/server codec accept a payload the peer will refuse, so both ends
    ///   must agree on the same in-range bound.
    /// - `circuit_breaker_reset == Duration::ZERO`: a breaker that reopens with
    ///   no cool-down hammers a down peer with no backoff.
    ///
    /// `circuit_breaker_threshold == 0` is intentionally allowed: the breaker
    /// treats a zero threshold as "open on the first failure" (fail-fast), a
    /// legitimate—if aggressive—policy rather than a degenerate one.
    ///
    /// [`GrpcTransport::new`]: crate::transport::GrpcTransport::new
    pub fn validate(&self) -> Result<(), GrpcTransportError> {
        if self.channel_capacity == 0 {
            return Err(GrpcTransportError::Internal(
                "channel_capacity must be > 0 (a zero-capacity mpsc channel panics)".into(),
            ));
        }
        for (name, d) in [
            ("connect_timeout", self.connect_timeout),
            ("request_timeout", self.request_timeout),
            ("keep_alive_interval", self.keep_alive_interval),
            ("keep_alive_timeout", self.keep_alive_timeout),
        ] {
            if d.is_zero() {
                return Err(GrpcTransportError::Internal(format!(
                    "{name} must be > 0 (a zero deadline aborts every attempt instantly)"
                )));
            }
        }
        if self.max_payload_bytes == 0 || self.max_payload_bytes > MAX_PAYLOAD_BYTES {
            return Err(GrpcTransportError::Internal(format!(
                "max_payload_bytes must be in 1..={MAX_PAYLOAD_BYTES}, got {}",
                self.max_payload_bytes
            )));
        }
        if self.circuit_breaker_reset.is_zero() {
            return Err(GrpcTransportError::Internal(
                "circuit_breaker_reset must be > 0 (a zero cool-down hammers a down peer)".into(),
            ));
        }
        Ok(())
    }
}

impl Default for GrpcTransportConfig {
    fn default() -> Self {
        Self {
            local_shard: ShardId::SINGLE,
            // Default replication listener. Inside tidalDB's reserved dev band
            // (59520–59529); 59529 is the slot claimed for the gRPC transport.
            listen_addr: "127.0.0.1:59529".parse().expect("valid default addr"),
            peers: HashMap::new(),
            tls: None,
            insecure: true,
            channel_capacity: 1024,
            max_payload_bytes: MAX_PAYLOAD_BYTES,
            circuit_breaker_threshold: 5,
            circuit_breaker_reset: Duration::from_secs(30),
            connect_timeout: Duration::from_secs(5),
            request_timeout: Duration::from_secs(10),
            keep_alive_interval: Duration::from_secs(10),
            keep_alive_timeout: Duration::from_secs(5),
        }
    }
}

/// TLS certificate paths for mutual TLS.
#[derive(Debug, Clone)]
pub struct TlsConfig {
    /// Path to the CA certificate PEM file.
    pub ca_cert: PathBuf,
    /// Path to the server certificate PEM file.
    pub server_cert: PathBuf,
    /// Path to the server private key PEM file.
    pub server_key: PathBuf,
    /// Path to the client certificate PEM file (for mTLS).
    pub client_cert: Option<PathBuf>,
    /// Path to the client private key PEM file (for mTLS).
    pub client_key: Option<PathBuf>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_config_is_valid() {
        // The shipped defaults must pass their own invariant check, otherwise
        // every default-built transport would error at construction.
        GrpcTransportConfig::default()
            .validate()
            .expect("default config must satisfy its own invariants");
    }

    #[test]
    fn zero_channel_capacity_is_rejected() {
        // A zero-capacity mpsc channel panics inside GrpcTransport::new; the
        // typed error must catch it before that panic.
        let cfg = GrpcTransportConfig {
            channel_capacity: 0,
            ..GrpcTransportConfig::default()
        };
        let err = cfg
            .validate()
            .expect_err("zero channel_capacity must be rejected");
        assert!(matches!(err, GrpcTransportError::Internal(_)));
    }

    #[test]
    fn zero_timeouts_are_rejected() {
        let mutators: [fn(&mut GrpcTransportConfig); 4] = [
            |c| c.connect_timeout = Duration::ZERO,
            |c| c.request_timeout = Duration::ZERO,
            |c| c.keep_alive_interval = Duration::ZERO,
            |c| c.keep_alive_timeout = Duration::ZERO,
        ];
        for mutate in mutators {
            let mut cfg = GrpcTransportConfig::default();
            mutate(&mut cfg);
            assert!(
                cfg.validate().is_err(),
                "a zero deadline must be rejected (it aborts every attempt instantly)"
            );
        }
    }

    #[test]
    fn zero_circuit_breaker_reset_is_rejected() {
        let cfg = GrpcTransportConfig {
            circuit_breaker_reset: Duration::ZERO,
            ..GrpcTransportConfig::default()
        };
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn payload_ceiling_out_of_range_is_rejected() {
        // Zero rejects every segment; above the engine wire limit lets the codec
        // accept a payload the peer will refuse.
        for bytes in [0, MAX_PAYLOAD_BYTES + 1] {
            let cfg = GrpcTransportConfig {
                max_payload_bytes: bytes,
                ..GrpcTransportConfig::default()
            };
            assert!(
                cfg.validate().is_err(),
                "max_payload_bytes {bytes} is out of the 1..={MAX_PAYLOAD_BYTES} range"
            );
        }
    }

    #[test]
    fn payload_ceiling_at_engine_limit_is_allowed() {
        let cfg = GrpcTransportConfig {
            max_payload_bytes: MAX_PAYLOAD_BYTES,
            ..GrpcTransportConfig::default()
        };
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn zero_circuit_breaker_threshold_is_allowed() {
        // A zero threshold means "open on the first failure" (fail-fast), a
        // legitimate aggressive policy — not a degenerate config.
        let cfg = GrpcTransportConfig {
            circuit_breaker_threshold: 0,
            ..GrpcTransportConfig::default()
        };
        assert!(cfg.validate().is_ok());
    }
}
