//! Helper functions for the app's terminal UI.
use anyhow::{Context, Result};
use config::Config;
use console::Term;
use omry_service::archiving::{ClientDateTimeRecord, Document, Record, RecordMeta};
use omry_service::common::RecordId;
use omry_service::irpc::channel::mpsc::Receiver;
use owo_colors::OwoColorize;
use semver::Version;
use std::fmt::Display;
use std::io;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::instrument;

use crate::app::App;
use crate::bridge::ServiceBridge;
use crate::command::CycleCommand;
use crate::common;
use crate::config as cli_config;
use crate::display;
use crate::{StoredConfig, VERSION};

/// Returns the ASCII art logo.
#[must_use]
pub fn logo() -> &'static str {
    include_str!("../assets/logo_ascii.txt")
}

/// Converts the given input to a boolean value.
///
/// True values: 'y', 'yes'.
/// False values: 'n', 'no'.
///
/// ## Errors
/// Returns [`anyhow::Error`] if the input isn't one of the above true/false values.
pub(crate) fn str_to_bool(input: &str) -> Result<bool> {
    const TRUE: &[&str] = &["y", "yes"];
    const FALSE: &[&str] = &["n", "no"];

    if TRUE.contains(&input) {
        return Ok(true);
    } else if FALSE.contains(&input) {
        return Ok(false);
    }

    let allowed_true_vals = TRUE.join("/");
    let allowed_false_vals = FALSE.join("/");
    anyhow::bail!("{input} is not one of {allowed_true_vals} or {allowed_false_vals}")
}

/// Outputs the given (error) text in yellow.
///
/// ## Errors
/// Returns [`anyhow::Error`] if the call to `write` fails.
pub(crate) fn write_error(text: impl Display) -> Result<()> {
    let term = Term::stderr();
    let text = format!("{}", text.red());
    term.write_line(&text)?;
    Ok(())
}

/// Constructs the message to display when we can't make an initial connection to the backend.
pub(crate) fn client_conn_err_help_msg(err: &dyn std::error::Error) -> String {
    const SUGGESTIONS: &[&str] = &[
        "- Check that omry-server is running and reachable",
        "- Make sure you're using the correct auth token",
        "- Check that the client and server are compatible in their version of omry-service",
    ];

    let svc_version = omry_service::VERSION;
    let suggestions = SUGGESTIONS.join("\n");

    format!(
        "The Omry CLI client couldn't communicate with the backend server.\n
To troubleshoot:\n\n{suggestions}\n
omry-cli version: {VERSION}; built with omry-service {svc_version}\nOriginal error: {err}"
    )
}

/// Displays the given error we got when creating RPC client, along with
/// some text that could help the user troubleshoot it.
pub(crate) fn show_client_conn_err(err: &dyn std::error::Error) -> Result<()> {
    let err_msg = client_conn_err_help_msg(err);
    write_error(&err_msg)
}

/// Creates a warning message to show if the [`omry-service`] version on the backend is incompatible
/// (per SemVer) with the version used in `omry-cli`.
pub(crate) fn version_mismatch_warn_msg(
    svc_version_client: &Version,
    svc_version_server: &Version,
) -> String {
    let version_client = svc_version_client.green();
    let version_server = svc_version_server.blue();
    let version_mismatch_msg =
        "Omry client and server are built with incompatible versions of omry-service".yellow();

    let compat_warning_msg = "This could cause unexpected behaviour, or prevent omry-cli from communicating with the server.".yellow();

    format!(
        "{version_mismatch_msg}:\n
omry-cli (client): {version_client}; omry-server: {version_server}\n{compat_warning_msg}"
    )
}

/// Returns [`true`] if versions are compatible, according to SemVer.
pub(crate) fn versions_compatible(version_client: &Version, version_server: &Version) -> bool {
    // While we're at pre-1.0, both major and minor versions have to match.
    if version_client.major == 0 {
        version_client.major == version_server.major && version_client.minor == version_server.minor
    } else {
        version_client.major == version_server.major
    }
}

/// Creates a regular (non-warning) message to report the versions
/// of [`omry-service`] on the client and the server.
pub(crate) fn version_msg(svc_version_client: &Version, svc_version_server: &Version) -> String {
    let version_client = svc_version_client.cyan();
    let version_msg_client = format!("omry-cli service version: {version_client}");
    let version_msg_client = version_msg_client.bright_black();

    let version_server = svc_version_server.blue();
    let version_msg_server = format!("omry server service version: {version_server}");
    let version_msg_server = version_msg_server.bright_black();
    format!("{version_msg_client}; {version_msg_server}")
}

/// Displays the version mismatch warning from [`version_mismatch_warn_msg`].
pub(crate) fn show_svc_versions(
    svc_version_client: &Version,
    svc_version_server: &Version,
) -> Result<()> {
    let term = Term::stdout();
    if versions_compatible(svc_version_client, svc_version_server) {
        let svc_version_msg = version_msg(svc_version_client, svc_version_server);
        term.write_line(&svc_version_msg)?;
    } else {
        let warning_msg = version_mismatch_warn_msg(svc_version_client, svc_version_server);
        term.write_line(&warning_msg)?;
    }

    let client_app_version = common::VERSION.green();
    let version_msg = format!("omry-cli app version: {client_app_version}");
    let version_msg = version_msg.bright_black().to_string();
    term.write_line(&version_msg)?;
    Ok(())
}

/// Wraps [`Term::read_line_initial_text`] in [`tokio::task::spawn_blocking`] for convenience.
pub(crate) async fn read_line_initial_text(initial: impl AsRef<str>) -> Result<String> {
    let initial = format!("{}", initial.as_ref().cyan());
    let term = Term::stdout();
    let term_clone = term.clone();
    let input =
        tokio::task::spawn_blocking(move || term_clone.read_line_initial_text(&initial)).await??;

    term.clear_line()?;

    Ok(input)
}

/// Prompts the user for the address of the Omry backend server (RPC and web, respectively),
/// and returns the addresses.
///
/// ## Errors
/// Returns [`anyhow::Error`] if we can't:
///
/// - Show the text prompt
/// - Read in the input from the terminal
fn prompt_server_url() -> Result<String> {
    const DEFAULT_WEB_URL: &str = "http://localhost:37374";
    let term = Term::stdout();
    let info_needed = "URL".cyan();
    let web_input_prompt = format!(
        "enter the Omry web server {info_needed}, including the scheme (e.g. {DEFAULT_WEB_URL}): "
    );

    let web_input = term.read_line_initial_text(&web_input_prompt)?;
    if web_input.is_empty() {
        anyhow::bail!("Server URL can't be empty.");
    }

    term.clear_line()?;
    Ok(web_input)
}

/// Prompts the user to enter the auth token.
fn prompt_auth_token() -> Result<String> {
    let term = Term::stdout();
    let auth_token_clr = "auth token".cyan();
    let auth_token_prompt = format!("enter the {auth_token_clr}: ");
    let auth_token = term.read_line_initial_text(&auth_token_prompt)?;
    term.clear_line()?;
    Ok(auth_token)
}

/// Prompts the user for the required config values, and stores
/// them in the default config location.
#[instrument]
pub(crate) fn init_config() -> Result<StoredConfig> {
    let web_addr = loop {
        if let Ok(addr) = prompt_server_url() {
            break addr;
        }
    };

    let auth_token = loop {
        if let Ok(auth_token) = prompt_auth_token() {
            break auth_token;
        }
    };

    let config = Config::builder()
        .set_default(cli_config::WEB_ADDR_KEY, web_addr)?
        .set_default(cli_config::AUTH_TOKEN_KEY, auth_token)?
        .build()?
        .try_into()?;

    let config = StoredConfig::from_config(config)?;
    if let Err(error) = config.try_write() {
        let term = Term::stderr();
        term.write_line(format!("encountered error trying to save the config: {error}").as_str())?;
    }

    Ok(config)
}

/// Attempts to either load an existing config, or prompt the user for the required values.
#[instrument]
pub(crate) fn get_config() -> Result<StoredConfig> {
    let config = match StoredConfig::read_existing() {
        Ok(config) => {
            tracing::info!("found existing config");
            config
        }
        Err(error) => {
            tracing::warn!("couldn't read the config: {error}");
            init_config()?
        }
    };

    Ok(config)
}

/// A view of a record we could have.
#[derive(Debug)]
pub enum RecordView {
    /// Full record.
    Record(Record),

    /// A record's metadata.
    RecordMeta(RecordMeta),
}

impl RecordView {
    /// Returns the record's SQLite ID.
    pub fn id(&self) -> RecordId {
        #[allow(
            clippy::expect_used,
            reason = "All records from the server will have an ID."
        )]
        match self {
            Self::Record(r) => r.id().expect("Records should have an ID"),
            Self::RecordMeta(r) => r.id,
        }
    }

    /// Returns the original URL for this record.
    pub(crate) fn url(&self) -> &str {
        match self {
            Self::Record(r) => r.url(),
            Self::RecordMeta(r) => r.url.as_str(),
        }
    }

    /// Returns the client's date and time from when they saved this record.
    pub(crate) fn client_datetime(&self) -> &ClientDateTimeRecord {
        match self {
            Self::Record(r) => r.client_datetime(),
            Self::RecordMeta(r) => &r.client_datetime,
        }
    }

    /// Returns the record's original title, as extracted from the page.
    pub(crate) fn title(&self) -> &str {
        match self {
            Self::Record(r) => r.title(),
            Self::RecordMeta(r) => &r.title,
        }
    }

    /// If the inner value is a [`Record`], returns a reference
    /// to its content.
    ///
    /// If the inner value is a [`RecordMeta`], returns `None`.
    pub(crate) fn document(&self) -> Option<&Document> {
        match self {
            Self::Record(r) => Some(r.document()),
            Self::RecordMeta(_) => None,
        }
    }

    /// Consumes the wrapper and returns the inner value, if it is a [`Record`].
    pub fn into_record(self) -> Option<Record> {
        #[allow(clippy::match_wildcard_for_single_variants)]
        match self {
            Self::Record(r) => Some(r),
            _ => None,
        }
    }

    /// Consumes the wrapper and returns the inner value, if it is a [`RecordMeta`].
    pub fn into_record_meta(self) -> Option<RecordMeta> {
        #[allow(clippy::match_wildcard_for_single_variants)]
        match self {
            Self::RecordMeta(rm) => Some(rm),
            _ => None,
        }
    }
}

impl From<Record> for RecordView {
    fn from(record: Record) -> Self {
        Self::Record(record)
    }
}

impl From<RecordMeta> for RecordView {
    fn from(record_meta: RecordMeta) -> Self {
        Self::RecordMeta(record_meta)
    }
}

/// Marker trait to satisfy trait bounds checks.
pub trait IntoView: Into<RecordView> + Send + Sync + 'static {}

impl<T> IntoView for T where T: Into<RecordView> + Send + Sync + 'static {}

/// A record view that has been receiver from the stream
/// (i.e. receiver) returned by the omry API.
#[derive(Debug)]
pub struct IndexedRecView<'p> {
    /// A record view returned by the stream.
    pub record: &'p RecordView,

    /// Position of this record in the original stream.
    pub idx: usize,
}

/// 3VL to represent the possible states of a result from a channel.
#[derive(Debug)]
enum StreamResult<R> {
    /// Variant denoting that the stream successfully returned a value,
    /// and it is not the end of a stream.
    Item(R),

    /// Signals that this stream has been exhausted,
    /// but there may be more items.
    StreamExhausted,

    /// Signals that we've reached the end of all items.
    End,
}

impl<R> StreamResult<R>
where
    R: Send + Sync + 'static,
{
    /// Turns a nested Option into a 3VL.
    #[allow(clippy::option_option)]
    fn new(maybe_item: Option<Option<R>>) -> Self {
        match maybe_item {
            Some(Some(item)) => Self::Item(item),
            Some(None) => Self::End,
            None => Self::StreamExhausted,
        }
    }
}

/// A trait for types that wrap receivers from paginated RPCs.
#[allow(async_fn_in_trait)]
pub trait PaginatedReceiver {
    /// Returns a receiver for the current page.
    fn rx(&mut self) -> &mut Receiver<Option<impl IntoView>>;

    /// Returns the current offset from the beginning;
    fn offset(&self) -> &AtomicUsize;

    /// Increments the current offset.
    ///
    /// This is used internally within the provided methods,
    /// and should not be called manually.
    fn incr_offset(&self) {
        self.offset().fetch_add(1, Ordering::Relaxed);
    }

    /// Fetches the "next" channel with the new offset.
    async fn next_page(&mut self) -> Result<()>;

    /// Returns the next record (if any), either streamed or cached
    /// as well as its position in the original stream.
    ///
    /// Returns [`None`] if we have reached the end of the stream or the cache.
    async fn next(&mut self) -> Result<Option<RecordView>> {
        loop {
            let maybe_item = self
                .rx()
                .recv()
                .await
                .context("Failed to read from the receiver")
                .map(StreamResult::new)?;

            match maybe_item {
                StreamResult::Item(r) => {
                    self.incr_offset();
                    return Ok(Some(r.into()));
                }
                StreamResult::StreamExhausted => {
                    self.next_page()
                        .await
                        .context("Failed to get the next stream")?;
                }
                StreamResult::End => {
                    return Ok(None);
                }
            }
        }
    }
}

/// A receiver for records, where `None` indicates we've reached the end of all pages.
type RecordReceiver = Receiver<Option<Record>>;

/// A wrapper for receivers from RPCs that return paginated records.
pub struct Records {
    /// The app to interact with the RPC server.
    app: App,

    /// Current offset.
    offset: AtomicUsize,

    /// The receiver from the RPC.
    rx: RecordReceiver,

    /// The record IDs.
    record_ids: Vec<RecordId>,
}

impl Records {
    /// Fetches the receiver for the first page (no offset), and
    /// creates state to paginate through all results.
    pub(crate) async fn new(app: App, record_ids: Vec<RecordId>) -> Result<Self> {
        let rx = app
            .get_records(record_ids.clone())
            .await
            .context("failed to get record receiver")?;
        Ok(Self {
            app,
            offset: AtomicUsize::default(),
            rx,
            record_ids,
        })
    }
}

impl PaginatedReceiver for Records {
    #[allow(refining_impl_trait)]
    fn rx(&mut self) -> &mut RecordReceiver {
        &mut self.rx
    }

    fn offset(&self) -> &AtomicUsize {
        &self.offset
    }

    async fn next_page(&mut self) -> Result<()> {
        self.rx = self
            .app
            .get_records(self.record_ids.clone())
            .await
            .context("failed to get record receiver")?;
        Ok(())
    }
}

/// A receiver for records' metadata, where `None` indicates we've reached the end of all pages.
type RecordMetaReceiver = Receiver<Option<RecordMeta>>;

/// A wrapper for receivers from `all_records_meta`, which handles pagination.
pub struct Metas {
    /// The app to interact with the RPC server.
    app: App,

    /// Current offset.
    offset: AtomicUsize,

    /// The receiver returned by the most recent RPC call.
    rx: RecordMetaReceiver,
}

impl Metas {
    /// Fetches the receiver for the first page (no offset), and
    /// creates state to paginate through all results.
    pub(crate) async fn new(app: App) -> Result<Self> {
        let rx = app
            .get_all_records_meta(None)
            .await
            .context("failed to get RecordMeta receiver")?;
        Ok(Self {
            app,
            offset: AtomicUsize::default(),
            rx,
        })
    }
}

impl PaginatedReceiver for Metas {
    #[allow(refining_impl_trait)]
    fn rx(&mut self) -> &mut RecordMetaReceiver {
        &mut self.rx
    }

    fn offset(&self) -> &AtomicUsize {
        &self.offset
    }

    async fn next_page(&mut self) -> Result<()> {
        let offset = self.offset().load(Ordering::Relaxed);
        self.rx = self
            .app
            .get_all_records_meta(Some(offset))
            .await
            .context("failed to get RecordMeta receiver")?;
        Ok(())
    }
}

/// A wrapper for receivers from `most_recent_meta`, which handles pagination.
pub struct RecentMetas {
    /// The app to interact with the RPC server.
    app: App,

    /// Current offset.
    offset: AtomicUsize,

    /// The receiver returned by the most recent RPC call.
    rx: RecordMetaReceiver,
}

impl RecentMetas {
    /// Fetches the receiver for the first page (no offset), and
    /// creates state to paginate through all results.
    pub(crate) async fn new(app: App) -> Result<Self> {
        let rx = app
            .most_recent_meta(None)
            .await
            .context("error getting 'most_recent_meta' receiver")?;
        Ok(Self {
            app,
            offset: AtomicUsize::default(),
            rx,
        })
    }
}

impl PaginatedReceiver for RecentMetas {
    #[allow(refining_impl_trait)]
    fn rx(&mut self) -> &mut RecordMetaReceiver {
        &mut self.rx
    }

    fn offset(&self) -> &AtomicUsize {
        &self.offset
    }

    async fn next_page(&mut self) -> Result<()> {
        let offset = self.offset().load(Ordering::Relaxed);
        self.rx = self
            .app
            .most_recent_meta(Some(offset))
            .await
            .context("error getting 'most_recent_meta' receiver")?;
        Ok(())
    }
}

/// Structure holding records returned by the server.
///
/// Can return streamed records while the receiver from the server
/// is alive, and cached records after that.
///
/// Will be fused, similarly to [`FusedIterator`]. Once the stream
/// is exhausted, will continue returning records from the cache.
#[derive(Debug)]
pub struct RecordProducer<P> {
    /// Paginated receiver returned by the app.
    rx: P,

    /// Records cached after they have been streamed.
    cache: Vec<RecordView>,

    /// Counter for the current record.
    idx: AtomicUsize,

    /// Marks that all paginated results from the server have been exhausted.
    reached_end: bool,
}

impl<P> RecordProducer<P>
where
    P: PaginatedReceiver,
{
    /// Initializes this producer with the given receiver
    /// returned by the API.
    pub(crate) fn new(rx: P) -> Self {
        Self {
            rx,
            cache: vec![],
            idx: AtomicUsize::new(0),
            reached_end: false,
        }
    }

    /// Returns the next item in the stream, if any.
    async fn recv(&mut self) -> Result<Option<RecordView>> {
        let maybe_item = self
            .rx
            .next()
            .await
            .context("Failed to read from the receiver")?;
        Ok(maybe_item)
    }

    /// Returns the next record (if any), either streamed or cached
    /// as well as its position in the original stream.
    ///
    /// Returns [`None`] if we have reached the end of the stream or the cache.
    async fn next(&mut self) -> Result<Option<IndexedRecView<'_>>> {
        let mut idx = self.idx.load(Ordering::Relaxed);

        // not checking `stream_exhausted` because `prev` might have been called
        // while the stream is still live
        if idx < self.cache.len() {
            self.idx.store(idx + 1, Ordering::Relaxed);
            #[allow(clippy::expect_used)]
            let record = self.cache.get(idx).expect("We're within the cache bounds");
            return Ok(Some(IndexedRecView { record, idx }));
        }

        let record = if let Some(rec) = self.recv().await? {
            self.cache.push(rec);
            self.cache.last()
        } else {
            self.reached_end = true;
            idx = 0;
            self.cache.get(idx)
        };

        match record {
            Some(record) => {
                self.idx.store(idx + 1, Ordering::Relaxed);
                Ok(Some(IndexedRecView { record, idx }))
            }
            None => Ok(None),
        }
    }

    /// Gets the next record from the stream (if iterating the first time)
    /// or the cache (if the stream has already been exhausted).
    ///
    /// Resets and loops around when the last record is reached.
    pub(crate) async fn next_fused(&mut self) -> Result<Option<IndexedRecView<'_>>> {
        let idx = self.idx.load(Ordering::Relaxed);
        if self.reached_end && idx == self.cache.len() {
            self.idx.store(0, Ordering::Relaxed);
        }

        self.next().await
    }

    /// Returns the record before the current index.
    pub(crate) fn rewind(&self) {
        let idx = self.idx.load(Ordering::Relaxed);
        if idx == 0 {
            return;
        }

        self.idx.fetch_sub(1, Ordering::Relaxed);
    }

    /// Consumes this producer, and returns a collection
    /// of all records returned by the API.
    pub async fn into_vec(mut self) -> Result<Vec<RecordView>> {
        if self.reached_end {
            Ok(self.cache)
        } else {
            while let Some(rec) = self.recv().await.context("failed to get streamed record")? {
                self.cache.push(rec);
            }
            Ok(self.cache)
        }
    }
}

/// Shows intro text for items that have an index, possibly offset.
pub(crate) fn intro_cycle_idx(current_idx: usize, len: usize) -> io::Result<()> {
    let display_idx = current_idx + 1;
    let display_idx = display_idx.bright_green();
    let len = len.green();
    let index_msg = format!("Item {display_idx} of {len}:");
    let term = Term::stdout();
    term.write_line(&index_msg)
}

/// Prompts the user for input when cycling through sequences of items,
/// and parses it into the appropriate command.
pub(crate) async fn prompt_cycle_input() -> Result<CycleCommand> {
    let prompt_msg =
        "Would you like to (v)iew this item, (c)ontinue to next, go (b)ack one, or (s)top? > ";
    let input = read_line_initial_text(prompt_msg).await?;
    input.parse::<CycleCommand>()
}

/// Fetches and displays data for one record, given its ID.
pub(crate) async fn fetch_and_view_record(app: &App, id: RecordId) -> Result<()> {
    let mut rec_rx = app.get_records(vec![id]).await?;
    let rec = rec_rx.recv().await.context("error receiving record")?;

    let Some(Some(rec)) = rec else {
        anyhow::bail!("Couldn't get a record with id: {id}");
    };

    view_one_record(app, &rec.into())
}

/// Displays information about the given record.
pub(crate) fn view_one_record(app: &App, record: &RecordView) -> Result<()> {
    let url = app.local_url(record.id());
    let msg = format!(
        "Using default browser to open archived page (local URL: {}; original URL: {})...",
        url,
        record.url(),
    );

    let term = Term::stdout();
    term.write_line(&msg)?;
    let url = display::rewrite_url(&url);

    if let Err(error) = webbrowser::open(url) {
        let msg = format!("Couldn't open the browser: {error}");
        let msg = format!("{}", msg.bright_black());
        term.write_line(&msg)?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::set_snapshot_suffix;
    use rstest::rstest;

    #[test]
    fn can_get_logo() {
        let logo = logo();
        insta::assert_snapshot!(logo);
    }

    #[rstest]
    #[case("y")]
    #[case("yes")]
    #[case("n")]
    #[case("no")]
    #[case("floob")]
    fn can_convert_str_to_bool(#[case] input: &str) {
        set_snapshot_suffix!("{}", input);
        let result = str_to_bool(input);
        insta::assert_debug_snapshot!(result);
    }

    #[test]
    fn can_make_connection_troubleshooting_msg() {
        let dummy_err = anyhow::anyhow!("this is just a test");
        let msg = client_conn_err_help_msg(dummy_err.as_ref());
        insta::assert_snapshot!(msg);
    }

    #[rstest]
    #[case("0.8.3", "0.8.1")]
    #[case("1.1.1", "1.0.2")]
    fn can_check_versions_compatible(
        #[case] version_a: &str,
        #[case] version_b: &str,
    ) -> Result<()> {
        let version_a = version_a.parse::<Version>()?;
        let version_b = version_b.parse::<Version>()?;
        assert!(versions_compatible(&version_a, &version_b));
        Ok(())
    }

    #[rstest]
    #[case("0.9.3", "0.8.1")]
    #[case("2.1.1", "1.0.2")]
    fn can_check_versions_incompatible(
        #[case] version_a: &str,
        #[case] version_b: &str,
    ) -> Result<()> {
        let version_a = version_a.parse::<Version>()?;
        let version_b = version_b.parse::<Version>()?;
        assert!(!versions_compatible(&version_a, &version_b));
        Ok(())
    }

    #[test]
    fn can_create_version_mismatch_msg() -> Result<()> {
        const VERSION_A: &str = "0.8.0";
        const VERSION_B: &str = "0.9.0";
        let version_a = VERSION_A.parse::<Version>()?;
        let version_b = VERSION_B.parse::<Version>()?;
        let msg = version_mismatch_warn_msg(&version_a, &version_b);
        insta::assert_snapshot!(msg);
        Ok(())
    }

    #[test]
    fn can_create_version_msg() -> Result<()> {
        const VERSION_A: &str = "0.8.0";
        const VERSION_B: &str = "0.8.1";
        let version_a = VERSION_A.parse::<Version>()?;
        let version_b = VERSION_B.parse::<Version>()?;
        let msg = version_msg(&version_a, &version_b);
        insta::assert_snapshot!(msg);
        Ok(())
    }
}
