//! Text commands accepted from stdin.
use crate::common::RecordId;
use anyhow::Result;
use std::fmt::{self, Display};
use std::num::ParseIntError;
use std::path::PathBuf;
use std::str::FromStr;
use strum_macros::{EnumDiscriminants, EnumIter, EnumMessage, EnumString, IntoStaticStr};

/// Prefix that commands should start with.
pub(crate) const CMD_PREFIX: char = '/';

/// Variants for accepted commands.
#[derive(Debug, EnumDiscriminants, IntoStaticStr)]
#[strum_discriminants(derive(EnumIter, EnumString, EnumMessage, IntoStaticStr))]
#[strum_discriminants(doc = "Commands supported by the omry-cli.")]
#[strum_discriminants(name(CommandVariants))]
pub enum Command {
    /// `add \<path\>` - directs omry-cli to add the HTML file at the given path
    /// to the Omry archive.
    #[strum_discriminants(strum(
        message = "'/add <path>' - will add a single HTML, or (if <path> is a directory), all HTML files at the top level.",
        serialize = "/add"
    ))]
    Add(PathBuf),

    /// `delete id` - directs omry-cli to delete the record with the given ID
    /// `query id1, id2, ...` - multiple IDs
    #[strum_discriminants(strum(
        message = "'/delete <id>' or 'delete <id1,id2,...>' - will delete the record(s) with these database IDs",
        serialize = "/delete"
    ))]
    Delete(Vec<RecordId>),

    /// `help` - directs omry-cli to display help text.
    #[strum_discriminants(strum(
        message = "'/help [command]'- displays help text (optionally for the given command).",
        serialize = "/help",
    ))]
    Help(Option<CommandVariants>),

    /// `list` - directs omry-cli to list all documents stored in Omry archive.
    #[strum_discriminants(strum(
        message = "/list - lists all documents stored in Omry.",
        serialize = "/list"
    ))]
    List,

    /// `/recent` - shows all record metas in reverse chronological order.
    #[strum_discriminants(strum(
        message = "/recent - lists all documents stored in Omry, most recently added first.",
        serialize = "/recent"
    ))]
    Recent,

    // Clippy complains about unbalanced backticks here, I think because it doesn't understand
    // we're escaping them.
    #[allow(clippy::doc_markdown)]
    /// `search \<query\> - directs omry-cli to search flora with the given query.
    #[strum_discriminants(strum(
        message = "'/search <query>' - searches Omry with the given query.",
        serialize = "/search"
    ))]
    Search(String),

    /// `query id1` - directs omry-cli to query Omry for records with given record ID(s)
    /// `query id1,id2,...` - multiple IDs
    #[strum_discriminants(strum(
        message = "'/query <id>' or '/query <id1,id2,...>' - return records with the given DB IDs. Can take one or multiple IDs.",
        serialize = "/query",
    ))]
    Query(Vec<RecordId>),

    /// 'bye' | 'exit' | 'quit' | - directs omry-cli to terminate.
    #[strum_discriminants(strum(
        message = "'/bye' | '/exit' | '/quit' - exits omry-cli",
        serialize = "/bye",
        serialize = "/exit",
        serialize = "/quit"
    ))]
    Exit,
}

impl FromStr for Command {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self> {
        if s.is_empty() {
            anyhow::bail!("Empty input");
        }

        if !s.starts_with(CMD_PREFIX) {
            return parse_search(Some(s));
        }

        // Split input into two strings: [command, rest]
        let mut splits = s.splitn(2, ' ');

        // `splitn` always returns the remainder, even with an empty string,
        // so `expect` is ok here.
        #[allow(clippy::expect_used)]
        let command_tok = splits
            .next()
            .expect("splitn should always return the remainder")
            .strip_prefix(CMD_PREFIX)
            .expect("We checked that the command prefix should always be there");

        let rest = splits.next().map(str::trim);

        match command_tok {
            "add" => parse_add(rest),
            "delete" => parse_delete(rest),
            "list" => parse_list(rest),
            "recent" => parse_recent(rest),
            "search" => parse_search(rest),
            "query" => parse_query(rest),
            "help" => parse_help(rest),
            "bye" | "exit" | "quit" => parse_exit(rest),
            "" => anyhow::bail!("empty input"),
            _ => anyhow::bail!("unknown command: {s}"),
        }
    }
}

impl Display for Command {
    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
        let cmd: &'static str = self.into();
        write!(fmt, "{}{}", CMD_PREFIX, cmd.to_lowercase())
    }
}

/// Parses record IDs from the given text.
///
/// ## Errors
///
/// Returns [`anyhow::Error`] if we can't parse the IDs.
fn parse_ids(input: &str) -> std::result::Result<Vec<RecordId>, ParseIntError> {
    input
        .split(',')
        .map(str::trim)
        .map(str::parse::<i64>)
        .collect::<Result<Vec<_>, _>>()
}

/// Parses the input that corresponds to the "add" command.
fn parse_add(rest: Option<&str>) -> Result<Command> {
    let Some(path) = rest else {
        anyhow::bail!("'add' needs to be followed by a path to an HTML file");
    };

    // Sometimes when the users copy paths from other software,
    // they get wrapped in quotes.
    let path = path.trim_matches('"');
    let path = path.parse::<PathBuf>()?;
    Ok(Command::Add(path))
}

/// Parses the input that corresponds to the "delete" command.
fn parse_delete(rest: Option<&str>) -> Result<Command> {
    let Some(ids) = rest else {
        anyhow::bail!("'delete' needs to be followed by a record ID.");
    };

    let ids = parse_ids(ids)?;
    Ok(Command::Delete(ids))
}

/// Returns [`Command::List`].
///
/// This function currently ignores the arguments; they are included to have a consistent signature
/// with the other `parse_*` functions.
// We need this to return [`Result`] to maintain a consistent signature
// between all `parse_` functions.
#[allow(clippy::unnecessary_wraps)]
fn parse_list(_rest: Option<&str>) -> Result<Command> {
    Ok(Command::List)
}

/// Returns [`Command::Recent`].
///
/// All arguments are ignored.
#[allow(clippy::unnecessary_wraps)]
fn parse_recent(_rest: Option<&str>) -> Result<Command> {
    Ok(Command::Recent)
}

/// Parses the input that corresponds to the "search" command.
fn parse_search(rest: Option<&str>) -> Result<Command> {
    let Some(query) = rest else {
        anyhow::bail!("'search' needs to be followed by a text query");
    };

    Ok(Command::Search(query.to_string()))
}

/// Parses inputs that correspond to "exit" commands.
// We need this to return [`Result`] to maintain a consistent signature
// between all `parse_` functions.
#[allow(clippy::unnecessary_wraps)]
fn parse_exit(_rest: Option<&str>) -> Result<Command> {
    Ok(Command::Exit)
}

/// Parses input that corresponds to the "query" command.
fn parse_query(rest: Option<&str>) -> Result<Command> {
    let Some(ids) = rest else {
        anyhow::bail!("'query' needs to be followed by record ID(s)");
    };

    let ids = parse_ids(ids).map_err(|error| anyhow::anyhow!("Invalid ID input: {error}"))?;
    Ok(Command::Query(ids))
}

/// Parses input that corresponds to the "help" command.
fn parse_help(rest: Option<&str>) -> Result<Command> {
    let command = rest.map(CommandVariants::from_str).transpose()?;
    Ok(Command::Help(command))
}

/// Possible user actions when cycling through lists (e.g. records or search hits).
#[derive(Debug)]
pub(crate) enum CycleCommand {
    /// Indicates the user wishes to move to the next element in the sequence.
    Continue,

    /// Indicates the user wishes to move to the previous element in the sequence.
    Back,

    /// Indicates the user wishes to stop cycling through the sequence.
    Stop,

    /// Indicates the user wishes to select and view the current element in the sequence.
    View,
}

impl FromStr for CycleCommand {
    type Err = anyhow::Error;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match input {
            "c" => Ok(Self::Continue),
            "b" => Ok(Self::Back),
            "s" => Ok(Self::Stop),
            "v" => Ok(Self::View),
            _ => anyhow::bail!("Unknown action: {input}"),
        }
    }
}

/// Variants to encode possible states when the user ends
/// cycling through a list of records.
#[derive(Debug, Copy, Clone)]
pub(crate) enum CycleEnd {
    /// User reached the end of the current page.
    PageEnd,

    /// User requested to go back to the previous page.
    PrevPage,

    /// User chose to stop cycling through the records.
    Stop,
}

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

    use crate::set_snapshot_suffix;
    use anyhow::Result;
    use rstest::rstest;

    #[test]
    fn parsing_unknown_command_returns_error() {
        let test_input = "/bloo";

        // We want to panic if parsing doesn't return an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = test_input.parse::<Command>().unwrap_err();
        insta::assert_snapshot!(error);
    }

    #[test]
    fn empty_input_returns_error() {
        let test_input = "";

        // We want to panic if parsing doesn't return an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = test_input.parse::<Command>().unwrap_err();
        insta::assert_snapshot!(error);
    }

    #[rstest]
    #[case("/etc/something/here/page.html")]
    #[case("   /etc/something/here/page.html ")]
    #[case("\"/etc/something/here/page.html\"")]
    #[case("   \"/etc/something/here/page.html\"   ")]
    fn can_parse_add(#[case] input: &str) -> Result<()> {
        set_snapshot_suffix!("{input}");
        let test_input = format!("/add {input}");
        let command = test_input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[test]
    fn parsing_add_without_path_returns_error() {
        let test_input = "/add";

        // We should panic and fail the test if the return value isn't an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = test_input.parse::<Command>().unwrap_err();

        insta::assert_snapshot!(error);
    }

    #[test]
    fn can_parse_list() -> Result<()> {
        let test_input = "/list";
        let command = test_input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[test]
    fn can_parse_search() -> Result<()> {
        let test_input = "/search to do so i will give account";
        let command = test_input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[test]
    fn parsing_search_without_query_returns_error() {
        let test_input = "/search";

        // We want to panic if parsing doesn't return an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = test_input.parse::<Command>().unwrap_err();
        insta::assert_snapshot!(error);
    }

    #[test]
    fn no_colon_prefix_means_we_search() -> Result<()> {
        let test_input = "to do so, I will give you a complete account of the system";
        let command = test_input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[rstest]
    #[case::bye("/bye")]
    #[case::quit("/quit")]
    #[case::exit("/exit")]
    fn can_parse_exit_inputs(#[case] input: &str) -> Result<()> {
        let command = input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[rstest]
    #[case::query_one("/query 1")]
    #[case::query_multiple("/query 1,2,3")]
    #[case::query_multiple("/query 1, 2, 3")]
    fn can_parse_query(#[case] input: &str) -> Result<()> {
        set_snapshot_suffix!("{input}");
        let command = input.parse::<Command>()?;
        insta::assert_debug_snapshot!(command);
        Ok(())
    }

    #[test]
    fn query_without_ids_returns_error() {
        let input = "/query";

        // We should panic and fail the test if the return value isn't an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = input.parse::<Command>().unwrap_err();
        insta::assert_debug_snapshot!(error);
    }

    #[test]
    fn query_with_invalid_id_returns_error() {
        let input = "/query 1, 2, boo";

        // We should panic and fail the test if the return value isn't an error,
        // so `unwrap_err` is ok here.
        #[allow(clippy::unwrap_used)]
        let error = input.parse::<Command>().unwrap_err();
        insta::assert_debug_snapshot!(error);
    }

    #[rstest]
    #[case::continuing("c")]
    #[case::view("v")]
    #[case::stop("s")]
    fn can_parse_cycle_action(#[case] input: &str) -> Result<()> {
        set_snapshot_suffix!("{input}");
        let action = input.parse::<CycleCommand>()?;
        insta::assert_debug_snapshot!(action);
        Ok(())
    }

    #[rstest]
    #[case::completely_unknown("flora is ready")]
    #[case::leading_space(" c")]
    #[case::trailing_space("c ")]
    #[case::leading_trailing_space(" c ")]
    fn unknown_cycle_input_returns_error(#[case] input: &str) {
        set_snapshot_suffix!("{input}");
        let result = input.parse::<CycleCommand>();
        insta::assert_debug_snapshot!(result);
    }

    #[rstest]
    #[case::no_subcommand("/help")]
    #[case::add("/help /add")]
    #[case::help("/help /help")]
    #[case::list("/help /list")]
    #[case::search("/help /search")]
    #[case::query("/help /query")]
    #[case::bye("/help /bye")]
    #[case::exit("/help /exit")]
    #[case::quit("/help /quit")]
    #[case::unknown_subcommand("/help /floob")]
    fn can_parse_help(#[case] input: &str) {
        set_snapshot_suffix!("{input}");
        let result = input.parse::<Command>();
        insta::assert_debug_snapshot!(result);
    }

    #[rstest]
    #[case::delete_valid_id("/delete 1")]
    #[case::delete_multiple_ids("/delete 1,2,3")]
    #[case::delete_invalid_id("/delete floob")]
    fn can_parse_delete(#[case] input: &str) {
        set_snapshot_suffix!("{input}");
        let result = input.parse::<Command>();
        insta::assert_debug_snapshot!(result);
    }
}
