use anyhow::{Context, Result};
use omry_service::OmryApi;
use omry_service::iroh::endpoint::presets;
use omry_service::iroh::{Endpoint, EndpointAddr};
use secrecy::SecretString;
use std::env::consts;
use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
use testcontainers::Image;
use tracing::{Level, instrument};

use crate::containers::{ContainerConfig, ImageConfig, ImageExtConfig, get_bridge_gateway_ip};
use crate::flora_image::{FloraConfig, FloraContainer, start_flora_container};
use crate::full_stack::App;
use crate::helpers::{self, DockerHost, HostPorts, token};
use crate::typesense::TestTypesense;

/// Default tracing level for the flora test server.
const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;

/// Default interval at which the flora test server will sync new records to Typesense.
const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);

/// Value that corresponds to the host machine in Docker.
const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";

/// Length of the RPC auth token.
const AUTH_TOKEN_LEN: usize = 20;

/// Optional parameters for [`FloraStack`].
#[derive(Debug, Clone)]
pub struct FloraStackOptions {
    /// The tracing level to set for the test flora server.
    ///
    /// Corresponds to `FLORA_TRACING_LEVEL`.
    pub tracing_level: Level,

    /// The interval at which the test flora server will sync
    /// new records to Typesense.
    ///
    /// Corresponds to `FLORA_SEARCH_SYNC_INTERVAL_SECS`.
    pub search_sync_interval: Duration,
}

impl Default for FloraStackOptions {
    fn default() -> Self {
        Self {
            tracing_level: DEFAULT_TRACING_LEVEL,
            search_sync_interval: DEFAULT_SYNC_INTERVAL,
        }
    }
}

/// Flora stack (flora server and Typesense), running in testcontainers, for integration testing.
#[derive(Debug)]
pub struct FloraStack {
    /// RPC auth token.
    auth_token: String,

    /// Container for the Omry server.
    flora_server: FloraContainer,

    /// Typesense container.
    typesense: TestTypesense,
}

impl FloraStack {
    /// Creates a new instance of the Omry stack.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't:
    ///
    /// - Start the Typesense container
    /// - Get the client config to connect to Typesense
    /// - Map the Typesense host to Docker's platform-specific localhost address (this differs between Linux and macOS)
    /// - Start the Omry container
    #[instrument]
    pub async fn new(stack_options: Option<FloraStackOptions>) -> Result<Self> {
        let typesense_img_ext_conf = ImageExtConfig::default();
        let typesense_con_conf =
            ContainerConfig::new(ImageConfig::default(), typesense_img_ext_conf);
        let typesense = TestTypesense::new(typesense_con_conf)
            .await
            .map_err(|error| {
                anyhow::anyhow!("Couldn't start the test Typesense container: {error}")
            })?;

        let client_config = typesense
            .make_client_config()
            .await
            .map_err(|error| anyhow::anyhow!("{error}"))?;
        let Some(mut typesense_url) = client_config.url else {
            anyhow::bail!("Test Typesense client config doesn't have a URL value.");
        };

        // Workaround for connecting within docker.
        let remapped_host = match typesense_url.host_str() {
            Some("127.0.0.1" | "localhost") => match consts::OS {
                "macos" => Some(DOCKER_HOST_INTERNAL.to_string()),
                "linux" => Some(get_bridge_gateway_ip().await?),
                os => {
                    tracing::warn!("your OS ({os}) may be unsupported for docker tests");
                    None
                }
            },
            _ => None,
        };

        if let Some(host) = remapped_host {
            typesense_url.set_host(Some(&host))?;
        }

        let options = stack_options.unwrap_or_default();
        let auth_token = token::generate(AUTH_TOKEN_LEN);
        let flora_conf = FloraConfig::new(
            SecretString::new(auth_token.clone().into()),
            &typesense_url,
            client_config.api_key,
        )
        .with_tracing_level(options.tracing_level);

        let flora_image_conf = ImageConfig::default();
        let flora_image_ext_conf = ImageExtConfig::default();
        let flora_con_conf = ContainerConfig::new(flora_image_conf, flora_image_ext_conf);

        let flora_server = start_flora_container(flora_conf, flora_con_conf)
            .await
            .map_err(|error| anyhow::anyhow!("{error}"))?;

        Ok(FloraStack {
            auth_token,
            flora_server,
            typesense,
        })
    }

    /// Stops the Docker containers in this Omry stack.
    ///
    /// ## Errors
    /// Returns [`anyhow::Result`] if we can't:
    ///
    /// - Stop the Omry container
    /// - Stop the Typesense container
    #[instrument]
    pub async fn stop(&self) -> Result<()> {
        self.flora_server.stop().await?;
        self.typesense.container().stop().await?;
        Ok(())
    }

    /// Returns a reference to the flora server test container
    /// running as part of this stack.
    #[must_use]
    pub fn flora_container(&self) -> &FloraContainer {
        &self.flora_server
    }

    /// Returns the RPC auth token.
    #[must_use]
    pub fn auth_token(&self) -> &str {
        &self.auth_token
    }
}

/// Attempts to use [`testcontainers::ContainerAsync`] APIs
/// to get the host and host-side port for the given container.
///
/// This may not work if the container is configured to use host networking.
///
/// ## Errors
/// Returns [`anyhow::Error`] if we can't get the container host or port.
pub async fn get_host_and_ports(container: &FloraContainer) -> Result<(String, HostPorts)> {
    let [tarpc_port, web_port] = container.image().expose_ports() else {
        anyhow::bail!("Omry container doesn't expose ports for tarpc and web");
    };

    let host = if let Ok(docker_host) = DockerHost::from_env()
        && docker_host.is_remote()
    {
        docker_host
            .host()
            .ok_or_else(|| anyhow::anyhow!("Remote Docker host without a host component"))?
            .to_string()
    } else {
        container
            .get_host()
            .await
            .context("couldn't get the Omry container host")?
            .to_string()
    };

    let host_port_tarpc = container
        .get_host_port_ipv4(*tarpc_port)
        .await
        .context("couldn't get the tarpc host-side port for the Omry container")?;

    let host_port_web = container
        .get_host_port_ipv4(*web_port)
        .await
        .context("couldn't get the web host-side port for the Omry container")?;

    let ports = HostPorts {
        rpc: host_port_tarpc,
        web: host_port_web,
    };

    Ok((host, ports))
}

/// `irpc` and web socket addresses for the Omry server.
#[derive(Debug, Clone)]
pub struct OmryAddrs {
    /// `irpc` endpoint address.
    pub rpc: EndpointAddr,

    /// Web socket address.
    pub web: SocketAddr,
}

impl OmryAddrs {
    /// Gets the host and ports for the Omry server via its container.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    ///
    /// - The container is running on localhost, and we can't get its ports
    /// - We can't create socket addresses from the host and ports
    ///   (should never happen in practice)
    #[instrument]
    pub async fn new(container: &FloraContainer) -> Result<Self> {
        let (host, ports) = match get_host_and_ports(container).await {
            Ok((host, ports)) => (host, ports),
            Err(error) => {
                tracing::warn!(
                    "couldn't get the host port using the testcontainers APIs; trying workaround\
                \noriginal error: {error}"
                );

                // If we're running on localhost, we need to have host ports.
                let Some(ports) = container.image().host_ports() else {
                    anyhow::bail!("couldn't get the host-side port for the flora container");
                };

                (Ipv4Addr::LOCALHOST.to_string(), ports)
            }
        };

        let HostPorts { rpc: _, web } = ports;
        let web = format!("{host}:{web}")
            .to_socket_addrs()?
            .next()
            .ok_or_else(|| anyhow::anyhow!("No socket addresses"))
            .context("Couldn't create a socket address for web.")?;

        let web_url = format!("http://{web}");
        let rpc = helpers::endpoint_addr(&web_url).await?;

        Ok(Self { rpc, web })
    }
}

/// Spawns a new [`OmryApi`] connected to the given endpoint.
///
/// ## Errors
///
/// Returns [`anyhow::Error`] if we can't initialize the RPC connection.
#[instrument]
async fn spawn_client(server_addr: EndpointAddr) -> Result<OmryApi> {
    tracing::info!("connecting API");
    let endpoint = Endpoint::bind(presets::N0).await?;
    let api = OmryApi::connect(endpoint, server_addr);
    Ok(api)
}

/// Spawns a new [`OmryApi`] connected to the given address,
/// and authenticates with the given token.
#[allow(clippy::missing_errors_doc)]
#[instrument(skip(auth_token))]
pub async fn spawn_and_auth(server_addr: EndpointAddr, auth_token: String) -> Result<OmryApi> {
    let api = spawn_client(server_addr).await?;
    api.auth(auth_token).await?;
    Ok(api)
}

/// Spawns a new API instance connected to the Omry server
/// running in the given Docker container.
///
/// ## Errors
///
/// Returns [`anyhow::Error`] if we can't spawn the Omry service client.
#[instrument]
pub async fn api_container(stack: &FloraStack) -> Result<OmryApi> {
    let OmryAddrs { rpc, web: _ } = OmryAddrs::new(stack.flora_container()).await?;
    spawn_and_auth(rpc, stack.auth_token().into()).await
}

/// Returns an app configured to connect to the server
/// running in the given container.
///
/// # Errors
/// Returns [`anyhow::Error`] if we can't create the test API instance.
pub async fn app_container(stack: &FloraStack) -> Result<App> {
    api_container(stack)
        .await
        .context("couldn't create the test API instance")
        .map(App::new)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bridge::ApiBridge;
    use crate::helpers::{self, token};
    use omry_service::PONG;
    use pretty_assertions::assert_eq;
    use secrecy::SecretString;
    use std::time::Duration;
    use tokio::time;
    use url::Url;

    #[test]
    fn can_create_flora_config_with_options() -> Result<()> {
        let options = FloraStackOptions {
            tracing_level: Level::WARN,
            search_sync_interval: Duration::from_secs(37),
        };
        let url: Url = "https://my.test.typesense".try_into()?;
        let auth_token = SecretString::new(token::generate(20).into());
        let api_key = "very-secret".into();

        let flora_config =
            FloraConfig::new(auth_token, &url, api_key).with_tracing_level(options.tracing_level);
        insta::assert_debug_snapshot!(flora_config);

        Ok(())
    }

    #[tokio::test]
    async fn can_start_flora_stack() {
        assert!(FloraStack::new(None).await.is_ok());
    }

    #[tokio::test]
    async fn can_spawn_flora_client_connected_to_test_server() -> Result<()> {
        helpers::init_tracing_subscriber()?;

        let flora_stack = FloraStack::new(None).await?;

        // Give the flora server time to start.
        time::sleep(Duration::from_secs(5)).await;

        let app = app_container(&flora_stack)
            .await
            .context("couldn't spawn the test app")?;

        let ping_response = app.ping().await.context("couldn't send the ping request")?;
        assert_eq!(ping_response, PONG);
        Ok(())
    }

    #[tokio::test]
    async fn can_configure_flora_stack() -> Result<()> {
        helpers::init_tracing_subscriber()?;

        let options = FloraStackOptions::default();
        let flora_stack = FloraStack::new(Some(options)).await?;

        // Give the server time to start.
        tokio::time::sleep(Duration::from_secs(5)).await;

        let app = app_container(&flora_stack)
            .await
            .context("couldn't spawn the test app")?;

        tracing::trace!("sending ping request");
        let ping_response = app.ping().await.context("couldn't send the ping request")?;

        insta::assert_snapshot!(ping_response, PONG);

        Ok(())
    }
}
