//! Utilities for setting up and working with test containers.
use std::collections::HashMap;

use anyhow::Result;
use testcontainers::bollard::Docker;
use testcontainers::bollard::query_parameters::ListNetworksOptions;
use testcontainers::core::ContainerRequest;
use testcontainers::core::logs::consumer::{LogConsumer, logging_consumer::LoggingConsumer};
use testcontainers::{Image, ImageExt};

/// Trait alias for the [`LogConsumer`] trait with trait bounds we need to use it
/// with the [`ContainerConfig`] struct.
pub trait ContainerLogConsumer: LogConsumer + Send + Sync + 'static {}
impl<L> ContainerLogConsumer for L where L: LogConsumer + Send + Sync + 'static {}

/// Container configuration for the subset of [`ImageExt`] parameters
/// that we use in tests.
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug, Clone)]
pub struct ImageExtConfig<L>
where
    L: ContainerLogConsumer,
{
    container_name: Option<String>,
    network: Option<String>,
    log_consumer: Option<L>,
}

impl<L> ImageExtConfig<L>
where
    L: ContainerLogConsumer,
{
    /// Sets the container name in the config to the given string.
    #[must_use]
    pub fn with_container_name(mut self, container_name: String) -> Self {
        self.container_name = Some(container_name);
        self
    }

    /// Sets the container network name to the given string.
    #[must_use]
    pub fn with_network(mut self, name: String) -> Self {
        self.network = Some(name);
        self
    }

    /// Sets the log consumer for the container to the given log consumer.
    #[must_use]
    pub fn with_log_consumer(mut self, log_consumer: L) -> Self {
        self.log_consumer = Some(log_consumer);
        self
    }

    /// Calls the appropriate [`ImageExt`] methods for the values
    /// that have been set in the other methods.
    pub fn configure_container<RI, I>(self, image: RI) -> ContainerRequest<I>
    where
        RI: Into<ContainerRequest<I>>,
        I: Image,
    {
        let mut container_request = image.into();

        if let Some(container_name) = self.container_name {
            container_request = container_request.with_container_name(container_name);
        }

        if let Some(network) = self.network {
            container_request = container_request.with_network(network);
        }

        if let Some(log_consumer) = self.log_consumer {
            container_request = container_request.with_log_consumer(log_consumer);
        }

        container_request
    }
}

impl Default for ImageExtConfig<LoggingConsumer> {
    fn default() -> Self {
        Self {
            container_name: None,
            network: None,
            log_consumer: None,
        }
    }
}

/// Image configuration for the subset of [`Image`] parameters
/// that we use in tests.
#[derive(Debug, Copy, Clone)]
pub struct ImageConfig {
    /// Whether the image should be configured to expose any posts to the host.
    ///
    /// If [`true`], it is up to the [`Image`] implementation to decide
    /// which ports to expose, if any.
    pub should_expose_ports: bool,
}

impl Default for ImageConfig {
    /// Creates an [`ImageConfig`] with the default values that make sense
    /// for most tests.
    fn default() -> Self {
        Self {
            should_expose_ports: true,
        }
    }
}

impl ImageConfig {
    /// Sets `should_expose_ports` to the given value.
    #[must_use]
    pub fn with_expose_ports(mut self, should_expose_ports: bool) -> Self {
        self.should_expose_ports = should_expose_ports;
        self
    }
}

/// Struct to hold container configuration values.
#[derive(Debug)]
pub struct ContainerConfig<L>
where
    L: ContainerLogConsumer,
{
    /// Subset of options available for configuring [`Image`] implementations.
    pub image_config: ImageConfig,

    /// Subset of options available for configuring containers via [`ImageExt`] methods.
    pub image_ext_config: ImageExtConfig<L>,
}

impl<L> ContainerConfig<L>
where
    L: ContainerLogConsumer,
{
    /// Creates a new [`ContainerConfig`] with the given configuration values.
    pub fn new(image_config: ImageConfig, image_ext_config: ImageExtConfig<L>) -> Self {
        Self {
            image_config,
            image_ext_config,
        }
    }

    /// Alias for [`ImageExtConfig::configure_container`].
    pub fn configure_container<RI, I>(self, image: RI) -> ContainerRequest<I>
    where
        RI: Into<ContainerRequest<I>>,
        I: Image,
    {
        self.image_ext_config.configure_container(image)
    }
}

impl Default for ContainerConfig<LoggingConsumer> {
    fn default() -> Self {
        Self {
            image_config: ImageConfig::default(),
            image_ext_config: ImageExtConfig::default(),
        }
    }
}

/// Tries to get the gateway IP of the docker bridge network.
///
/// We need this to connect to local services within docker
/// regardless if we're running on Mac or linux.
///
/// ## Errors
/// Returns [`anyhow::Result`] if we can't:
///
/// - connect to Docker on the default socket
/// - list Docker networks
/// - find the 'bridge' network
/// - get the IPAM gateway IP for the bridge network
pub async fn get_bridge_gateway_ip() -> Result<String> {
    let docker = Docker::connect_with_socket_defaults()?;

    let mut filters = HashMap::new();
    let network_name = "bridge".to_string();
    filters.insert("name".to_string(), vec![network_name.clone()]);
    let list_options = ListNetworksOptions {
        filters: Some(filters),
    };

    let networks = docker.list_networks(Some(list_options)).await?;
    let bridge = networks
        .first()
        .ok_or_else(|| anyhow::anyhow!("can't find network '{network_name}'"))?;

    let gateway_ip = bridge
        .ipam
        .as_ref()
        .and_then(|ipam| ipam.config.as_ref())
        .and_then(|configs| configs.first())
        .and_then(|config| config.gateway.as_ref())
        .ok_or_else(|| {
            anyhow::anyhow!(
                "can't get the IPAM gateway IP for network. \
        full network object:\n{bridge:#?}"
            )
        })?;

    Ok(gateway_ip.clone())
}
