Documentation
// Copyright (c) 2025, Salesforce, Inc.,
// All rights reserved.
// For full license text, see the LICENSE.txt file

//! gRPC mock service for integration testing
//!
//! This module provides predefined types and configurations for GripMock services
//! in integration tests. [GripMock](https://github.com/tokopedia/gripmock) is a gRPC mock service useful for testing
//! gRPC clients with predefined responses and behaviors.
//!

use std::{
    path::{Path, PathBuf},
    time::Duration,
};

use crate::port::Port;
use crate::{
    config::{Config, ContainerConfig},
    container::Container,
    image::Image,
    port::PortAccess,
    probe::{MessageProbe, MessageSource},
    service::Service,
    TestError,
};

// Mocking port
const MOCKING_PORT: u16 = 4771;
// Service port
const SERVICE_PORT: u16 = 4770;
const GRIP_MOCK_SCHEMA: &str = "tcp";

/// Default GripMock Docker image name.
pub const GRIPMOCK_IMAGE_NAME: &str = "tkpd/gripmock";

/// Configuration for a `GripMock` service.
#[derive(Debug, Clone)]
pub struct GripMockConfig {
    hostname: String,
    version: String,
    image_name: String,
    proto: PathBuf,
    stub: Option<PathBuf>,
}

impl GripMockConfig {
    /// Returns the `GripMock` service hostname.
    pub fn hostname(&self) -> &str {
        &self.hostname
    }

    /// Returns the `GripMock` Docker version.
    pub fn version(&self) -> &str {
        &self.version
    }

    /// Returns the `GripMock` Docker image name.
    pub fn image_name(&self) -> &str {
        &self.image_name
    }

    /// Returns the path of the `.proto` file.
    pub fn proto(&self) -> &Path {
        &self.proto
    }

    /// Returns the path of the `stub` directory if configured.
    pub fn stub(&self) -> Option<&Path> {
        self.stub.as_deref()
    }

    /// Returns a builder for [`GripMockConfig`].
    pub fn builder() -> GripMockConfigBuilder {
        GripMockConfigBuilder::default()
    }
}

/// Builder for [`GripMockConfig`].
#[derive(Debug, Clone, Default)]
pub struct GripMockConfigBuilder {
    hostname: Option<String>,
    version: Option<String>,
    image_name: Option<String>,
    proto: Option<PathBuf>,
    stub: Option<PathBuf>,
}

impl GripMockConfigBuilder {
    /// Sets the `GripMock` service hostname.
    pub fn hostname<T: Into<String>>(self, hostname: T) -> Self {
        Self {
            hostname: Some(hostname.into()),
            ..self
        }
    }

    /// Sets the `GripMock` Docker version.
    pub fn version<T: Into<String>>(self, version: T) -> Self {
        Self {
            version: Some(version.into()),
            ..self
        }
    }

    /// Sets the `GripMock` Docker image name.
    /// By default set to [`GRIPMOCK_IMAGE_NAME`].
    pub fn image_name<T: Into<String>>(self, image_name: T) -> Self {
        Self {
            image_name: Some(image_name.into()),
            ..self
        }
    }

    /// Sets the path of the `.proto` file.
    pub fn proto<T: Into<PathBuf>>(self, proto: T) -> Self {
        Self {
            proto: Some(proto.into()),
            ..self
        }
    }

    /// Sets the path of the `stub` directory.
    pub fn stub<T: Into<PathBuf>>(self, stub: T) -> Self {
        Self {
            stub: Some(stub.into()),
            ..self
        }
    }

    /// Builds a [`GripMockConfig`] with the configured values.
    pub fn build(self) -> GripMockConfig {
        GripMockConfig {
            hostname: self.hostname.unwrap_or_else(|| "gripmock".to_string()),
            version: self.version.unwrap_or_else(|| "v1.13".to_string()),
            image_name: self
                .image_name
                .unwrap_or_else(|| GRIPMOCK_IMAGE_NAME.to_string()),
            proto: self.proto.expect("missing proto file"),
            stub: self.stub,
        }
    }
}

impl Config for GripMockConfig {
    fn hostname(&self) -> &str {
        self.hostname()
    }

    fn port(&self) -> Port {
        SERVICE_PORT
    }

    fn schema(&self) -> &str {
        GRIP_MOCK_SCHEMA
    }

    fn to_container_config(&self) -> Result<ContainerConfig, TestError> {
        let mut mounts = vec![(
            self.proto()
                .parent()
                .expect("expected a proto file")
                .as_os_str()
                .to_str()
                .expect("expected a valid proto file"),
            "proto",
            "",
        )];

        let mut cmd = Vec::new();

        if let Some(stub) = self.stub() {
            assert!(stub.is_dir(), "'stub' path must be a directory");

            mounts.push((
                stub.as_os_str()
                    .to_str()
                    .expect("expected a valid stub directory"),
                "stubs",
                "",
            ));

            cmd.push("--stub=/stubs".to_string());
        }

        cmd.push(format!(
            "/proto/{}",
            self.proto()
                .file_name()
                .and_then(|s| s.to_str())
                .expect("expected a valid proto file")
        ));

        Ok(ContainerConfig::builder(
            self.hostname(),
            Image::from_repository(self.image_name()).with_version(self.version()),
        )
        .ports([
            PortAccess::published(MOCKING_PORT),
            PortAccess::exposed(SERVICE_PORT),
        ])
        .mounts(mounts)
        .readiness(
            MessageProbe::builder("Serving")
                .times(2)
                .timeout(Duration::from_secs(30))
                .source(MessageSource::Any)
                .build(),
        )
        .cmd(cmd)
        .build())
    }
}

/// Represents a `GripMock` service instance.
#[derive(Debug)]
pub struct GripMock {
    address: String,
}

impl GripMock {
    /// Returns the socket for configuring the Gripmock service.
    pub fn address(&self) -> &str {
        &self.address
    }
}

impl Service for GripMock {
    type Config = GripMockConfig;

    fn new(_: &Self::Config, container: &Container) -> Self {
        let socket = container
            .socket(MOCKING_PORT)
            .expect("port should be configured")
            .to_string();

        let address = format!("http://{socket}");
        Self { address }
    }
}