//! "Stack" that includes a simple Omry client app, to facilitate integration tests
//! with a containerised Omry server.
use crate::flora_stack::{FloraStack, FloraStackOptions};
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
pub use test_app::App;
use tracing::instrument;

/// Omry client app for tests.
mod test_app {
    use anyhow::{Context, Result};
    use omry_service::OmryApi;
    use omry_service::search::{SearchParameters, SearchResponse, SortBy};
    use std::sync::Arc;
    use tokio::time::{self, Duration};
    use tracing::instrument;

    use crate::bridge::ApiBridge;
    use crate::flora_stack::{self, FloraStack};

    /// Client "app" for testing.
    #[derive(Debug, Clone)]
    pub struct App {
        /// Bridge API for the flora tarpc backend.
        api: OmryApi,
    }

    impl App {
        /// Initializes the app state.
        #[must_use]
        pub fn new(api: OmryApi) -> Self {
            Self { api }
        }

        /// Constructs [`SearchParameters`] with the given text query,
        /// and sends a search request to the Omry backend.
        ///
        /// Adds `sort_by` to the search parameters to sort hits by recency.
        ///
        /// ## Errors
        /// Returns [`anyhow::Error`] if the search RPC fails.
        #[instrument]
        pub async fn search(&self, query: String, page: Option<usize>) -> Result<SearchResponse> {
            const BUCKETS: u32 = 5;
            const QUERY_BY: &[&str] = &["title", "text-body"];

            let query_by = QUERY_BY
                .iter()
                .map(std::string::ToString::to_string)
                .collect::<Vec<_>>();

            let sort_by = SortBy::relevance_and_recency(BUCKETS);
            let page = page.unwrap_or(1);
            let params = SearchParameters::new_query(query, query_by)
                .num_typos(2)
                .sort_by(sort_by)
                .page(page);

            ApiBridge::search(self, params.into()).await
        }
    }

    impl ApiBridge for App {
        fn api(&self) -> &OmryApi {
            &self.api
        }
    }

    async fn ping(api: &OmryApi) -> Result<String> {
        let rx = api.ping().await?;
        Ok(rx.await?)
    }

    /// Initializes a test client app, and checks that it can communicate
    /// with the Omry server.
    #[instrument]
    pub(super) async fn init_app(flora_stack: Arc<FloraStack>) -> Result<App> {
        const MAX_CONN_ATTEMPTS: usize = 5;
        const BACKOFF_MULTIPLIER: u32 = 2;

        tokio::spawn(async move {
            let mut attempts_so_far = 0;
            let mut backoff_interval = Duration::from_secs(1);

            // Attempt to first create an Omry RPC client, then use that client
            // to initialize our RPC bridge.
            //
            // We need to recreate the client on every iteration of the loop
            // even if we initialized it successfully in a previous iteration
            // because the client's connection to the tarpc server can shut down
            // when we use tokio::time::sleep.

            loop {
                match flora_stack::api_container(&flora_stack).await {
                    Ok(api) => match ping(&api).await {
                        Ok(_) => break Ok(App::new(api)),
                        Err(error) => {
                            tracing::warn!("error while initializing the RPC client: {error}");
                        }
                    },
                    Err(error) => {
                        tracing::warn!("error while creating the flora client: {error}");
                    }
                }

                if attempts_so_far == MAX_CONN_ATTEMPTS {
                    anyhow::bail!("couldn't succeed after {MAX_CONN_ATTEMPTS} attempts");
                }

                time::sleep(backoff_interval).await;
                backoff_interval *= BACKOFF_MULTIPLIER;
                attempts_so_far += 1;
            }
        })
        .await
        .context("Error initializing the test app")?
    }
}

/// Flora test stack.
#[derive(Debug)]
pub struct OmryFullStack {
    /// Containerised Omry stack.
    backend: Arc<FloraStack>,
}

impl OmryFullStack {
    /// Creates a new test stack that includes an Omry backend (`omry-server` and Typesense),
    /// and a client app to facilitate integration testing.
    ///
    /// ## Errors
    ///
    /// Returns [`anyhow::Error`] if we fail to:
    /// - Initialize the backend stack
    /// - Initialize the client app and connect it to that backend stack
    #[instrument]
    pub async fn new() -> Result<Self> {
        let stack_options = FloraStackOptions {
            search_sync_interval: Duration::from_secs(5),
            ..Default::default()
        };

        let backend = Arc::new(FloraStack::new(Some(stack_options)).await?);
        tracing::info!("successfully initialized the test bridge");
        Ok(Self { backend })
    }

    /// Returns a reference to the Omry backend stack.
    #[must_use]
    pub fn backend(&self) -> &Arc<FloraStack> {
        &self.backend
    }

    /// Returns a reference to the test client app.
    ///
    /// # Errors
    ///
    /// Returns [`anyhow::Error`] if the test app can't be initialized
    /// (usually because it can't connect to the test backend server).    
    pub async fn init_app(&self) -> Result<App> {
        test_app::init_app(Arc::clone(self.backend())).await
    }

    /// Attempts to stop ("kill") this Omry stack, shutting down
    /// the connection(s) with the Omry backend.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't stop the Omry backend.
    pub async fn kill_flora_rpc(&mut self) -> Result<()> {
        self.backend.stop().await?;
        Ok(())
    }
}
