//! Module that contains the state of the CLI app.
use anyhow::{Context, Result};
use indicatif::ProgressBar;
use omry_service::OmryApi;
use omry_service::common::RecordId;
use omry_service::iroh::Endpoint;
use omry_service::search::{SearchParameters, SortBy};
use std::path::PathBuf;
use tokio::task::{self, JoinSet};
use tracing::{Instrument, instrument};

use crate::bridge::{self, ServiceBridge};
use crate::config::AppConfig;
use crate::ingest::{self, Import, ParseOpts};
use crate::search::{self, SearchPage};
use crate::ui_helpers::{Metas, RecentMetas, RecordProducer, Records};

/// The state of the CLI app.
#[derive(Debug, Clone)]
pub struct App {
    /// Bridge API for the flora tarpc backend.
    api: OmryApi,

    /// `omry-cli` configuration.
    config: AppConfig,

    /// iroh endpoint,
    endpoint: Option<Endpoint>,
}

impl App {
    /// Creates a new app by connecting to the API.
    ///
    /// Uses the given config to determine the endpoint address and auth token.
    #[allow(clippy::missing_errors_doc)]
    #[instrument]
    pub async fn new(config: AppConfig) -> Result<Self> {
        let (api, endpoint) = bridge::connect_and_auth(&config.web_addr, config.auth_token.clone())
            .await
            .context("Error connecting to the API")?;

        Ok(Self {
            api,
            config,
            endpoint: Some(endpoint),
        })
    }

    /// Initializes the app state from an already connected API instance.
    #[must_use]
    pub fn from_api(api: OmryApi, config: AppConfig) -> Self {
        Self {
            api,
            config,
            endpoint: None,
        }
    }

    /// If this app instance owns the iroh endpoint, will disconnect from it.
    ///
    /// This method is not fallible, but it's a logic error to call
    /// it if the app does not own the endpoint
    /// (if it was created using [`App::from_api`] instead of [`App::new`]).
    #[instrument(skip(self))]
    pub async fn disconnect(&self) {
        let Some(endpoint) = &self.endpoint else {
            // this is not a hard error, despite being a likely logic bug
            tracing::error!("This app instance does not own the endpoint, and cannot disconnect.");
            return;
        };

        tracing::trace!("Disconnecting.");
        endpoint.close().await;
    }

    /// Returns a reference to the application config, if one was provided.
    #[must_use]
    pub fn config(&self) -> &AppConfig {
        &self.config
    }

    /// Adds the document(s) (HTML file(s)) at the given path to the flora backend.
    ///
    /// ## Errors
    /// Returns [`anyhow::Result`] if:
    ///
    /// - The record(s) can't be loaded from the given path
    /// - The Omry backend couldn't import any record(s)
    #[instrument]
    pub async fn ingest(&self, path: PathBuf) -> Result<Vec<i64>> {
        let span = tracing::warn_span!("record_from_path").or_current();
        let parse_opts = ParseOpts {
            max_elements_to_parse: self.config.max_elements_to_parse,
        };

        let to_import =
            task::spawn_blocking(move || ingest::prepare_records(&path, Some(&parse_opts)))
                .instrument(span)
                .await??;

        let Import { prepared, failed } = to_import;

        if let Some(failed) = failed {
            tracing::error!("{} files couldn't be turned into records", failed.len());
        }

        let Some(prepared_record_params) = prepared else {
            anyhow::bail!("No records could be created from the given path");
        };

        let mut task_set = JoinSet::new();
        let mut added_ids = vec![];

        // idea: use indicatif to show progress
        for record_params in prepared_record_params {
            let app = self.clone();
            task_set.spawn(async move { app.add_record(record_params).await });
        }

        let num_tasks: u64 = task_set.len().try_into()?;
        let pro_bar = ProgressBar::new(num_tasks).with_message("Importing records to Omry...");

        while let Some(result) = task_set.join_next().await {
            pro_bar.tick();

            match result {
                Ok(Ok(result)) => added_ids.push(result),
                Ok(Err(err)) => {
                    tracing::error!("Couldn't import record: {err}");
                }
                Err(err) => {
                    tracing::error!("join error: {err}");
                }
            }
        }

        if added_ids.is_empty() {
            anyhow::bail!("No records could be imported.");
        }

        Ok(added_ids)
    }

    /// Constructs [`SearchParameters`] with the given text query,
    /// and sends a search request to the flora backend.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if the search RPC fails.
    #[instrument]
    pub async fn search(&self, query: String, page: Option<usize>) -> Result<SearchPage> {
        const BUCKETS: u32 = 5;
        const NUM_TYPOS: u8 = 2;
        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(NUM_TYPOS)
            .sort_by(sort_by)
            .page(page);

        let rx = ServiceBridge::search(self, params)
            .await
            .context("Couldn't call search")?;
        let resp = search::resp_from_stream(rx)
            .await
            .context("Can't assemble unified response from stream")?;
        Ok(resp.into())
    }

    /// Returns a view URL on the server for the given record ID.
    #[must_use]
    pub fn local_url(&self, id: RecordId) -> String {
        const VIEW_ROUTE: &str = "view";

        let web_addr = &self.config.web_addr;
        format!("{web_addr}/{VIEW_ROUTE}/{id}")
    }

    /// Fetches records with the given IDs from omry via RPC.
    #[allow(clippy::missing_errors_doc)]
    pub async fn records(&self, record_ids: Vec<RecordId>) -> Result<RecordProducer<Records>> {
        let records = Records::new(self.clone(), record_ids).await?;
        Ok(RecordProducer::new(records))
    }

    /// Fetches metadata for all records from omry.
    #[allow(clippy::missing_errors_doc)]
    pub async fn all_records_meta(&self) -> Result<RecordProducer<Metas>> {
        let metas = Metas::new(self.clone()).await?;
        Ok(RecordProducer::new(metas))
    }

    /// Fetches metadata for records in reverse chronological order.
    #[allow(clippy::missing_errors_doc)]
    pub async fn most_recent(&self) -> Result<RecordProducer<RecentMetas>> {
        let recent_metas = RecentMetas::new(self.clone()).await?;
        Ok(RecordProducer::new(recent_metas))
    }
}

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