//! UI-related functions.
use anyhow::{Context, Result};
use console::Term;
use dialoguer::{BasicHistory, Input};
use indicatif::ProgressBar;
use omry_service::VERSION as SVC_VERSION;
use owo_colors::{CssColors, OwoColorize};
use semver::Version;
use std::time::Duration;
use strum::{EnumMessage, IntoEnumIterator};
use tracing::instrument;

use crate::command::{CMD_PREFIX, Command, CommandVariants, CycleCommand};
use crate::display;
use crate::ingest::ParseOpts;
use crate::ui_helpers::{self, IndexedRecView, PaginatedReceiver, RecordProducer};
use crate::{App, AppConfig, ServiceBridge};

/// Search UI helpers.
mod search {
    use crate::App;
    use crate::command::{CycleCommand, CycleEnd};
    use crate::display;
    use crate::search::SearchPage;
    use crate::ui_helpers;
    use anyhow::{Context, Result};
    use chrono::DateTime;
    use console::Term;
    use omry_service::search::Hit;
    use owo_colors::OwoColorize;

    /// State for search UI.
    #[derive(Debug)]
    pub(super) struct SearchUi {
        /// Reference to the global UI state.
        app: App,

        /// Terminal instance for stdout.
        term: Term,

        /// Terminal instance for stderr.
        err_term: Term,

        /// Index of the current search page.
        page_idx: usize,

        /// Index of the current search hit we're on.
        hit_idx: usize,

        /// Current page with search results.
        page: SearchPage,

        /// The original search query.
        query: String,
    }

    impl SearchUi {
        /// Creates a new search UI state with the first page of
        /// results for the given query.
        async fn from_query(app: App, query: String) -> Result<Self> {
            let page_idx = 1;
            let hit_idx = 0;

            let page = app
                .search(query.clone(), Some(page_idx))
                .await
                .context("Error calling 'search'")?;

            let search = Self {
                app,
                term: Term::stdout(),
                err_term: Term::stderr(),
                page_idx,
                hit_idx,
                page,
                query,
            };
            Ok(search)
        }

        /// Updates the current search page with the results of the given query,
        /// for the current page index.
        async fn update_page(&mut self) -> Result<()> {
            self.page = self
                .app
                .search(self.query.clone(), Some(self.page_idx))
                .await
                .context("Error calling 'search'")?;
            Ok(())
        }

        /// Displays information about the given search hit.
        fn show_hit(&self, hit: &Hit) -> Result<()> {
            let title_msg = format!("Title: {}", hit.document.title().italic());
            self.term.write_line(&title_msg)?;
            self.term.write_line("")?;

            let url_msg = format!("URL: {}", hit.document.url());
            self.term.write_line(&url_msg)?;

            if let Some(timestamp) = DateTime::from_timestamp_secs(hit.document.timestamp()) {
                let msg = format!("Omry timestamp: {timestamp}");
                self.term.write_line(&msg)?;
            } else {
                let msg = format!("{}", "Couldn't parse the timestamp".dimmed());
                self.term.write_line(&msg)?;
            }

            let id_msg = format!("SQLite record ID: {}", hit.document.db_id().cyan());
            self.term.write_line(&id_msg)?;

            let match_score_msg = format!("Match score: {}", hit.text_match);
            self.term.write_line(&match_score_msg)?;
            self.term.write_line("")?;
            let highlights_msg = format!("{}", "Highlights:\n".bold());
            self.term.write_line(&highlights_msg)?;

            for highlight in &hit.highlights {
                let colorized = match display::colorize_highlight(&highlight.snippet) {
                    Ok(colorized) => colorized,
                    Err(err) => {
                        self.err_term.write_line(
                            format!("Couldn't colorize the highlights in snippet: {err}").as_str(),
                        )?;
                        highlight.snippet.clone()
                    }
                };

                let msg = format!("{}: {}\n", highlight.field, colorized.italic());
                self.term.write_line(&msg)?;
            }
            Ok(())
        }

        /// Cycles through the given search hits, wrapping around when the user reaches the end.
        async fn cycle_search_page(&self) -> Result<CycleEnd> {
            let hits = self.page.hits();

            // Index of the current hit, relative to the current page.
            // Using `saturating_sub` in case the search hits from
            // the server changed since the previous time we fetched them.
            let mut cur_rel_idx = self.hit_idx.saturating_sub(self.page.start_idx());

            loop {
                let cur_hit = hits.get(cur_rel_idx);
                let Some((hit, idx)) = cur_hit else {
                    return Ok(CycleEnd::PageEnd);
                };

                ui_helpers::intro_cycle_idx(*idx, self.page.total())?;
                self.term.write_line("")?;
                self.show_hit(hit)?;
                self.term.write_line("")?;

                let maybe_command = ui_helpers::prompt_cycle_input().await;
                match maybe_command {
                    Ok(CycleCommand::Continue) => {
                        cur_rel_idx += 1;
                    }
                    Ok(CycleCommand::Back) => {
                        if cur_rel_idx == 0 {
                            return Ok(CycleEnd::PrevPage);
                        }

                        cur_rel_idx = cur_rel_idx.saturating_sub(1);
                    }
                    Ok(CycleCommand::View) => {
                        let id = hit.document.db_id();
                        ui_helpers::fetch_and_view_record(&self.app, id).await?;
                    }
                    Ok(CycleCommand::Stop) => break,
                    Err(error) => {
                        ui_helpers::write_error(format!("Error processing command: {error}"))?;
                    }
                }
            }

            Ok(CycleEnd::Stop)
        }

        /// Allows the user to view and interact with search results.
        ///
        /// ## Returns
        ///
        /// [`Some(CycleEnd)`] if the user "cycled" results and either reached
        /// the end, or chose to stop.
        ///
        /// [`None`] if there were no results to display.
        async fn view_search_results(&self) -> Result<Option<CycleEnd>> {
            let response = self.page.full_response();
            let found_msg = format!("Found {} results", response.found.green());
            self.term.write_line(&found_msg)?;

            let stats_msg = format!(
                "(searched through {} records; time taken: {} ms)",
                response.out_of,
                response.search_time.as_millis()
            );

            let stats_msg = format!("{}", stats_msg.dimmed());
            self.term.write_line(&stats_msg)?;

            if response.found == 0 {
                return Ok(None);
            }

            Ok(Some(self.cycle_search_page().await?))
        }

        /// Starts the search result UI loop.
        async fn cycle(&mut self) -> Result<()> {
            loop {
                let Some(cycle_end) = self.view_search_results().await? else {
                    return Ok(());
                };

                match cycle_end {
                    CycleEnd::PageEnd => {
                        if self.page.is_last() {
                            let rollover_msg =
                                "That was the last item, going back to the beginning."
                                    .black()
                                    .on_yellow();

                            let rollover_msg = format!("{rollover_msg}");
                            self.term.write_line(&rollover_msg)?;
                            self.page_idx = 1;
                            self.hit_idx = 0;
                        } else {
                            self.page_idx += 1;
                            self.hit_idx = self.page.start_idx() + self.page.len();
                        }

                        self.update_page().await.context("Error updating page")?;
                    }
                    CycleEnd::PrevPage => {
                        if self.page.is_first() {
                            let first_page_msg =
                                "This is already the first page.".black().on_yellow();
                            let first_page_msg = format!("{first_page_msg}");
                            self.term.write_line(&first_page_msg)?;
                        } else {
                            self.page_idx -= 1;
                            self.hit_idx = self.page.start_idx() - 1;
                        }

                        self.update_page().await.context("Error updating page")?;
                    }
                    CycleEnd::Stop => break,
                }
            }

            Ok(())
        }

        /// Initialises the search UI for the given query.
        pub(super) async fn handle_query(app: App, query: String) -> Result<()> {
            let mut search = Self::from_query(app, query).await?;
            search.cycle().await
        }
    }
}

/// Interval for the spinner used when connecting to omry.
const SPINNER_TICK_INTERVAL: Duration = Duration::from_millis(100);

/// Initializes the [`App`] and invokes 'version' RPC on the server.
///
/// Takes config by value because it is passed into the [`App`].
#[instrument]
async fn init_conn(config: AppConfig) -> Result<App> {
    let app = App::new(config)
        .await
        .inspect_err(|e| {
            #[allow(
                clippy::expect_used,
                reason = "If we can't show the error, we should crash."
            )]
            ui_helpers::show_client_conn_err(e.as_ref()).expect("Should show the error");
        })
        .context("Cannot initialise the app")?;
    let version_client = SVC_VERSION.parse::<Version>()?;
    let version_server = match app.version().await {
        Ok(version) => version,
        Err(e) => {
            let msg = "Couldn't get the omry-service version from the server.";
            ui_helpers::write_error(msg)?;
            ui_helpers::show_client_conn_err(e.as_ref())?;
            anyhow::bail!("{e}");
        }
    };

    ui_helpers::show_svc_versions(&version_client, &version_server)?;
    Ok(app)
}

/// Abstraction that enables user interaction.
#[derive(Debug)]
pub struct Ui {
    /// Omry app.
    app: App,

    /// Handle to [`Term::stderr`].
    err_term: Term,

    /// Handle to [`Term::stdout`].
    term: Term,
}

impl Ui {
    /// Creates a new instance of UI state.
    ///
    /// Internally, initializes communication with the omry backend.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we fail to:
    ///
    /// - Load the existing config for the application
    /// - Start a new connection to the backend from that config
    #[instrument]
    pub async fn new() -> Result<Self> {
        let config = tokio::task::spawn_blocking(ui_helpers::get_config).await??;
        let term = Term::stdout();
        let err_term = Term::stderr();

        let conn_line = format!("{}", "Opening connection to omry...".dimmed());
        let spinner = ProgressBar::new_spinner().with_message(conn_line);
        spinner.enable_steady_tick(SPINNER_TICK_INTERVAL);
        let app = init_conn(config.config().clone()).await?;
        spinner.finish_and_clear();

        Ok(Self {
            app,
            err_term,
            term,
        })
    }

    /// Pings omry server, and attempts to reconnect if the ping fails.
    async fn check_reconnect(&mut self) -> Result<()> {
        const MAX_ATTEMPTS: u8 = 3;
        const SLEEP_FOR: Duration = Duration::from_secs(5);

        if let Err(error) = self.app.ping().await {
            let conn_msg = format!(
                "{}",
                "Lost connection to omry, trying to reconnect...".dimmed()
            );

            self.app.disconnect().await;
            let mut n_attempts = 0;
            while n_attempts < MAX_ATTEMPTS {
                let config = self.app.config().clone();
                let spinner = ProgressBar::new_spinner().with_message(conn_msg.clone());
                spinner.enable_steady_tick(SPINNER_TICK_INTERVAL);

                if let Ok(app) = App::new(config).await
                    && app.ping().await.is_ok()
                {
                    self.app = app;
                    spinner.finish_and_clear();

                    let reconnected_msg =
                        format!("{}", "Reconnected!".color(CssColors::ForestGreen));
                    self.term.write_line(&reconnected_msg)?;

                    return Ok(());
                }

                spinner.finish_and_clear();
                let retry_msg = format!(
                    "Couldn't reconnect, will wait {} seconds and try again",
                    SLEEP_FOR.as_secs()
                );
                self.err_term.write_line(&retry_msg)?;

                n_attempts += 1;
                tokio::time::sleep(SLEEP_FOR).await;
            }

            anyhow::bail!("couldn't connect to Omry: {error}");
        }

        Ok(())
    }

    /// Starts the UI loop.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we fail to communicate with the Omry backend,
    /// or process the user interaction.
    pub async fn run_loop(&mut self) -> Result<()> {
        const HISTORY_LIMIT: usize = 15;
        const WELCOME_TEXT: &str = "welcome to Omry CLI";

        let logo = ui_helpers::logo();
        self.term.write_line(logo)?;
        self.term.write_line(WELCOME_TEXT)?;

        let mut history = BasicHistory::new().max_entries(HISTORY_LIMIT);

        loop {
            self.check_reconnect()
                .await
                .context("Failed to connect to Omry")?;

            self.term.write_line("")?;
            let num_records = self.app.count().await?;
            let record_count_line = format!(
                "There are {} record(s) in the database.",
                num_records.cyan()
            );

            self.term.write_line(&record_count_line)?;

            self.term.write_line("")?;
            let help_cmd = Command::Help(None).to_string();
            let help_cmd_msg = help_cmd.bold();
            let help_msg = format!("enter {help_cmd_msg} for help");
            self.term.write_line(&help_msg)?;

            let prompt = format!("enter search query or {CMD_PREFIX}command ");
            let input = Input::<String>::new()
                .with_prompt(prompt.as_str())
                .history_with(&mut history)
                .interact_text()?;

            match input.parse::<Command>() {
                Ok(command) => match self.handle_command(command).await {
                    Ok(true) => {}
                    Ok(false) => break,
                    Err(error) => {
                        let error_msg = format!("Couldn't handle the command: {error}");
                        self.err_term.write_line(&error_msg)?;
                    }
                },
                Err(error) => {
                    let error_msg = format!("{error}");
                    self.err_term.write_line(&error_msg)?;
                }
            }
        }

        let disconnect_msg = format!("{}", "Disconnecting from omry...".dimmed());
        let spinner = ProgressBar::new_spinner().with_message(disconnect_msg);
        spinner.enable_steady_tick(SPINNER_TICK_INTERVAL);

        self.app.disconnect().await;
        spinner.finish_and_clear();
        Ok(())
    }

    /// Handles the action from [`Command::Delete`].
    async fn handle_delete(&self, ids: Vec<i64>) -> Result<()> {
        let confirmed = loop {
            let info_msg = format!(
                "You want to delete {} record(s). Once deleted, you won't be able to recover them.",
                ids.len()
            );

            let info_msg = info_msg.bold();
            let v_styled = "v".cyan();
            let view_msg = format!("Enter {v_styled} to view the records before deleting them.");
            let confirm_msg = "Are you sure you want to do this? y/n ".red();
            let confirm_msg = confirm_msg.bold();
            let msg = format!("{info_msg}\n{confirm_msg}\n{view_msg}");
            self.term.write_line(&msg)?;

            let input = ui_helpers::read_line_initial_text("> ").await?;
            match input.as_str() {
                "v" => {
                    let producer = self.app.records(ids.clone()).await?;
                    self.view_records(producer)
                        .await
                        .context("Couldn't view records")?;
                }
                _ => match ui_helpers::str_to_bool(&input) {
                    Ok(input) => break input,
                    Err(error) => {
                        ui_helpers::write_error(error)?;
                    }
                },
            }
        };

        if confirmed {
            self.app.delete(ids).await?;
            self.term
                .write_line("Sent the request to delete these records.")?;
        }

        Ok(())
    }

    /// Handles the given command, and passes the appropriate action to the flora backend.
    ///
    /// Returns true unless the command requests we exit the application [`Command::Exit`].
    #[instrument(skip(self))]
    async fn handle_command(&self, command: Command) -> Result<bool> {
        match command {
            Command::Add(path) => {
                if path.is_dir() {
                    let directory_line = format!(
                        "{}",
                        "This path is a directory. Will attempt to add all HTML files at the top level."
                            .magenta()
                    );

                    self.term.write_line(&directory_line)?;
                }

                let record_ids = self.app.ingest(path).await?;
                let Some(last_id) = record_ids.iter().max() else {
                    anyhow::bail!("No record IDs were returned.");
                };

                let added_msg = format!(
                    "Added {} records. Last record ID: {}",
                    record_ids.len().cyan(),
                    last_id.blue()
                );

                self.term.write_line(&added_msg)?;
            }
            Command::Delete(ids) => {
                self.handle_delete(ids).await?;
            }
            Command::Help(subcommand) => {
                let help_msg = if let Some(subcommand) = subcommand {
                    subcommand.get_message().map_or_else(
                        || format!("{}", "This subcommand is missing a message.".dimmed()),
                        |msg| format!("{}", msg.bold()),
                    )
                } else {
                    let commands = CommandVariants::iter()
                        .map(std::convert::Into::into)
                        .collect::<Vec<&str>>();

                    let commands = commands.join("\n");
                    let commands_msg = commands.bold();

                    let help_hint_msg = format!("{} /<command>", Command::Help(None));
                    let help_hint_msg = help_hint_msg.bold();

                    format!("Available commands:\n{commands_msg}\n\nYou can enter {help_hint_msg}")
                };

                self.term.write_line(&help_msg)?;
            }
            Command::Query(ids) => {
                let producer = self.app.records(ids.clone()).await?;
                self.view_records(producer)
                    .await
                    .context("Couldn't view records")?;
            }
            Command::Search(query) => {
                search::SearchUi::handle_query(self.app.clone(), query).await?;
            }
            Command::List => {
                let producer = self.app.all_records_meta().await?;
                self.view_records(producer)
                    .await
                    .context("Couldn't view records")?;
            }
            Command::Recent => {
                let producer = self.app.most_recent().await?;
                self.view_records(producer)
                    .await
                    .context("Couldn't view records")?;
            }
            Command::Exit => {
                return Ok(false);
            }
        }

        Ok(true)
    }

    /// Cycles through the given records, wrapping around when the user reaches the end.
    async fn view_records<P>(&self, mut producer: RecordProducer<P>) -> Result<()>
    where
        P: PaginatedReceiver,
    {
        loop {
            let Ok(Some(IndexedRecView { record, idx })) = producer.next_fused().await else {
                let empty_msg =
                    format!("{}", "Couldn't fetch any records".color(CssColors::Orange));
                self.term.write_line(&empty_msg)?;
                return Ok(());
            };

            if idx == 0 {
                let new_cycle_msg = format!(
                    "{}",
                    "This is the first item in the set.".black().on_yellow()
                );
                self.term.write_line(&new_cycle_msg)?;
            }

            // Put a line before showing the record info.
            self.term.write_line("")?;
            let record_id = record.id();
            let title_msg = format!("Title: {}", record.title().italic());
            self.term.write_line(&title_msg)?;

            let record_id = record_id.cyan();
            let url_msg = format!(
                "URL: {}\nSaved at {}\nSQLite record ID: {}",
                record.url(),
                record.client_datetime(),
                record_id,
            );
            self.term.write_line(&url_msg)?;

            if let Some(document) = record.document() {
                let parse_opts = ParseOpts {
                    max_elements_to_parse: self.app.config().max_elements_to_parse,
                };

                let article =
                    display::extract_document_text(document, record.url(), Some(parse_opts))?;

                let excerpt_msg = if let Some(excerpt) = article.excerpt {
                    format!("Excerpt: \"{}\"", excerpt.italic())
                } else {
                    format!("{}", "[no visible excerpt]".italic())
                };

                self.term.write_line(&excerpt_msg)?;
                self.term.write_line("")?;
            }

            let maybe_command = ui_helpers::prompt_cycle_input().await;
            match maybe_command {
                Ok(CycleCommand::Continue) => {}
                Ok(CycleCommand::Back) => {
                    // Rewind twice to end up at the previous element,
                    // not just the current one,
                    // when we call `next_fused` in the next iteration.
                    producer.rewind();
                    producer.rewind();
                }
                Ok(CycleCommand::Stop) => break,
                Ok(CycleCommand::View) => {
                    ui_helpers::view_one_record(&self.app, record)?;
                }
                Err(error) => {
                    let err_msg = format!(
                        "{}: {}",
                        "Error parsing input".color(CssColors::OrangeRed),
                        error
                    );
                    self.err_term.write_line(&err_msg)?;

                    // Rewind to avoid advancing on next iteration.
                    producer.rewind();
                }
            }
        }

        Ok(())
    }
}
