mod cli;

use cli::Cli;
use petname::Alliterations;
use petname::{Generator, Namer, Petnames};

use std::fmt;
use std::fs;
use std::io;
use std::path;
use std::process;

use clap::{CommandFactory, Parser};
use rand::SeedableRng;

fn main() {
    let cli = Cli::parse();

    // Manage stdout and buffer in a single scope so that `Drop` impls are
    // called before we handle `run`'s result, e.g. by exiting the process.
    let result = {
        let stdout = io::stdout();
        let mut writer = io::BufWriter::new(stdout.lock());
        run(cli, &mut writer)
    };

    match result {
        Ok(()) | Err(Error::Disconnected) => {
            process::exit(0);
        }
        Err(e) => {
            eprintln!("Error: {e}");
            process::exit(1);
        }
    }
}

#[derive(Debug)]
enum Error {
    Io(io::Error),
    FileIo(path::PathBuf, io::Error),
    Randomness(String),
    Cardinality(String),
    Alliteration(String),
    #[cfg(feature = "lang-turkish")]
    Unsupported(String),
    Disconnected,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Error::Io(ref e) => write!(f, "{e}"),
            Error::FileIo(ref path, ref e) => write!(f, "{e}: {}", path.display()),
            Error::Randomness(ref message) => write!(f, "no source of randomness: {message}"),
            Error::Cardinality(ref message) => write!(f, "cardinality is zero: {message}"),
            Error::Alliteration(ref message) => write!(f, "cannot alliterate: {message}"),
            #[cfg(feature = "lang-turkish")]
            Error::Unsupported(ref message) => write!(f, "unsupported: {message}"),
            Error::Disconnected => write!(f, "caller disconnected / stopped reading"),
        }
    }
}

impl From<io::Error> for Error {
    fn from(error: io::Error) -> Self {
        Error::Io(error)
    }
}

fn run<OUT>(cli: Cli, writer: &mut OUT) -> Result<(), Error>
where
    OUT: io::Write,
{
    // Subcommands are additive; handle them before the default path.
    match cli.command {
        Some(cli::Command::Completions { shell }) => {
            let mut command = Cli::command();
            clap_complete::generate(shell, &mut command, "petname", writer);
            Ok(())
        }
        None => run_default(cli, writer),
    }
}

fn run_default<OUT>(cli: Cli, writer: &mut OUT) -> Result<(), Error>
where
    OUT: io::Write,
{
    // We're going to need a source of randomness.
    let mut rng = if let Some(seed) = cli.seed {
        rand::rngs::StdRng::seed_from_u64(seed)
    } else {
        rand::rngs::StdRng::try_from_rng(&mut rand::rngs::SysRng)
            .map_err(|err| Error::Randomness(err.to_string()))?
    };

    // Stream, or print a limited number of words?
    let count = if cli.stream { None } else { Some(cli.count) };

    // Non-English languages use their own generators.
    match cli.language {
        cli::Language::English => run_english(&cli, writer, &mut rng, count),
        #[cfg(feature = "lang-turkish")]
        cli::Language::Turkish => run_turkish(&cli, writer, &mut rng, count),
    }
}

fn run_english<OUT, RNG>(
    cli: &Cli,
    writer: &mut OUT,
    rng: &mut RNG,
    count: Option<usize>,
) -> Result<(), Error>
where
    OUT: io::Write,
    RNG: rand::Rng,
{
    // Load custom word lists, if specified.
    let words = match cli.directory {
        Some(ref dirname) => Words::load(dirname)?,
        None => Words::Builtin,
    };

    // Select the appropriate word list.
    let mut petnames = match words {
        Words::Custom(ref adjectives, ref adverbs, ref nouns) => Petnames::new(adjectives, adverbs, nouns),
        Words::Builtin => match cli.lists {
            cli::WordList::Small => Petnames::small(),
            cli::WordList::Medium => Petnames::medium(),
            cli::WordList::Large => Petnames::large(),
        },
    };

    // If requested, limit the number of letters.
    let letters = cli.letters;
    if letters != 0 {
        petnames.retain(|s| s.len() <= letters);
    }

    // Check cardinality.
    if petnames.cardinality(cli.words) == 0 {
        return Err(Error::Cardinality("no petnames to choose from; try relaxing constraints".to_string()));
    }

    // Get an iterator for the names we want to print out, handling alliteration.
    if cli.alliterate || cli.ubuntu {
        let mut alliterations: Alliterations = petnames.into();
        alliterations.retain(|_, group| group.cardinality(cli.words) > 0);
        if alliterations.cardinality(cli.words) == 0 {
            return Err(Error::Alliteration("word lists have no initial letters in common".to_string()));
        }
        printer(writer, &alliterations.namer(cli.words, &cli.separator), rng, count)
    } else if let Some(alliterate_with) = cli.alliterate_with {
        let mut alliterations: Alliterations = petnames.into();
        alliterations.retain(|first_letter, group| {
            *first_letter == alliterate_with && group.cardinality(cli.words) > 0
        });
        if alliterations.cardinality(cli.words) == 0 {
            return Err(Error::Alliteration(
                "no petnames begin with the chosen alliteration character".to_string(),
            ));
        }
        printer(writer, &alliterations.namer(cli.words, &cli.separator), rng, count)
    } else {
        printer(writer, &petnames.namer(cli.words, &cli.separator), rng, count)
    }
}

/// Generate Turkish names using the [`petname::lang::turkish::Petnames`] generator.
#[cfg(feature = "lang-turkish")]
fn run_turkish<OUT, RNG>(
    cli: &Cli,
    writer: &mut OUT,
    rng: &mut RNG,
    count: Option<usize>,
) -> Result<(), Error>
where
    OUT: io::Write,
    RNG: rand::Rng,
{
    if cli.directory.is_some() {
        return Err(Error::Unsupported("--dir is not supported with --language turkish".to_string()));
    }
    if cli.alliterate || cli.ubuntu || cli.alliterate_with.is_some() {
        return Err(Error::Unsupported("alliteration is not supported with --language turkish".to_string()));
    }

    let mut turkish = petname::lang::turkish::Petnames::small();

    // If requested, limit the number of letters. Count characters, not bytes,
    // since Turkish words contain multi-byte code points.
    if cli.letters != 0 {
        turkish.retain(|s| s.chars().count() <= cli.letters);
    }

    // Check cardinality.
    if turkish.cardinality(cli.words) == 0 {
        return Err(Error::Cardinality("no petnames to choose from; try relaxing constraints".to_string()));
    }

    printer(writer, &turkish.namer(cli.words, &cli.separator), rng, count)
}

fn printer<OUT, GEN, RNG>(
    writer: &mut OUT,
    namer: &Namer<'_, GEN>,
    rng: &mut RNG,
    count: Option<usize>,
) -> Result<(), Error>
where
    OUT: io::Write,
    GEN: Generator,
    RNG: rand::Rng,
{
    let mut buf = String::new();
    match count {
        None => loop {
            namer.generate_into(&mut buf, rng);
            if buf.is_empty() {
                break;
            } else {
                writeln!(writer, "{buf}").map_err(suppress_disconnect)?;
                buf.clear();
            }
        },
        Some(n) => {
            for _ in 0..n {
                namer.generate_into(&mut buf, rng);
                if buf.is_empty() {
                    break;
                } else {
                    writeln!(writer, "{buf}")?;
                    buf.clear();
                }
            }
        }
    }

    writer.flush().map_err(suppress_disconnect)?;

    Ok(())
}

enum Words {
    Custom(String, String, String),
    Builtin,
}

impl Words {
    // Load word lists from the given directory. This function expects to find three
    // files in that directory: `adjectives.txt`, `adverbs.txt`, and `nouns.txt`.
    // Each should be valid UTF-8, and contain words separated by whitespace.
    fn load<T: AsRef<path::Path>>(dirname: T) -> Result<Self, Error> {
        let dirname = dirname.as_ref();
        Ok(Self::Custom(
            read_file_to_string(dirname.join("adjectives.txt"))?,
            read_file_to_string(dirname.join("adverbs.txt"))?,
            // Load `nouns.txt`, but fall back to trying `names.txt` for
            // compatibility with Dustin Kirkland's _petname_.
            match read_file_to_string(dirname.join("nouns.txt")) {
                Ok(nouns) => nouns,
                Err(err) => match read_file_to_string(dirname.join("names.txt")) {
                    Ok(nouns) => nouns,
                    Err(_) => Err(err)?, // Error from `nouns.txt`.
                },
            },
        ))
    }
}

fn read_file_to_string<P: AsRef<path::Path>>(path: P) -> Result<String, Error> {
    fs::read_to_string(&path).map_err(|error| Error::FileIo(path.as_ref().to_path_buf(), error))
}

fn suppress_disconnect(err: io::Error) -> Error {
    match err.kind() {
        io::ErrorKind::BrokenPipe => Error::Disconnected,
        _ => err.into(),
    }
}

/// Integration tests for the command-line `petname`.
///
/// These ensure command-line argument compatibility with those supported in
/// Dustin Kirkland's [`petname`](https://github.com/dustinkirkland/petname) as
/// well as testing the functionality of this package's command-line interface.
///
#[cfg(test)]
mod integration {
    use std::fs;

    use clap::Parser;

    fn run_and_capture(cli: super::Cli) -> String {
        let mut stdout = Vec::new();
        super::run(cli, &mut stdout).unwrap();
        String::from_utf8(stdout).unwrap()
    }

    #[test]
    fn option_words() {
        let cli = super::Cli::parse_from(["petname", "--words=5"]);
        assert_eq!(run_and_capture(cli).split('-').count(), 5);
    }

    #[test]
    fn option_letters() {
        let cli = super::Cli::parse_from(["petname", "--letters=3", "--count=100", "--separator= "]);
        assert_eq!(run_and_capture(cli).split_whitespace().map(str::len).max(), Some(3))
    }

    #[test]
    fn option_separator() {
        let cli = super::Cli::parse_from(["petname", "--separator=<:>"]);
        assert_eq!(run_and_capture(cli).split("<:>").count(), 2)
    }

    /// A directory can be specified containing `adverbs.txt`, `adjectives.txt`,
    /// and `nouns.txt`.
    #[test]
    fn option_dir_nouns() -> anyhow::Result<()> {
        let dir = tempfile::TempDir::with_prefix("petname")?;
        fs::write(dir.path().join("adverbs.txt"), "adverb")?;
        fs::write(dir.path().join("adjectives.txt"), "adjective")?;
        fs::write(dir.path().join("nouns.txt"), "noun")?;

        let args: &[std::ffi::OsString] =
            &["petname".into(), "--dir".into(), dir.path().into(), "--words=3".into()];
        let cli = super::Cli::parse_from(args);
        assert_eq!(run_and_capture(cli), "adverb-adjective-noun\n");
        Ok(())
    }

    /// A directory can be specified containing `adverbs.txt`, `adjectives.txt`,
    /// and `nouns.txt`/`names.txt`. If both `nouns.txt` and `names.txt` are
    /// present, `nouns.txt` is preferred.
    #[test]
    fn compat_dir_nouns_before_names() -> anyhow::Result<()> {
        let dir = tempfile::TempDir::with_prefix("petname")?;
        fs::write(dir.path().join("adverbs.txt"), "adverb")?;
        fs::write(dir.path().join("adjectives.txt"), "adjective")?;
        fs::write(dir.path().join("nouns.txt"), "noun")?;
        fs::write(dir.path().join("names.txt"), "name")?;

        let args: &[std::ffi::OsString] =
            &["petname".into(), "--dir".into(), dir.path().into(), "--words=3".into()];
        let cli = super::Cli::parse_from(args);
        assert_eq!(run_and_capture(cli), "adverb-adjective-noun\n");
        Ok(())
    }

    /// A directory can be specified containing `adverbs.txt`, `adjectives.txt`,
    /// and `names.txt`. The latter (`names.txt`) is only for compatibility with
    /// Dustin Kirkland's _petname_.
    #[test]
    fn compat_dir_names() -> anyhow::Result<()> {
        let dir = tempfile::TempDir::with_prefix("petname")?;
        fs::write(dir.path().join("adverbs.txt"), "adverb")?;
        fs::write(dir.path().join("adjectives.txt"), "adjective")?;
        fs::write(dir.path().join("names.txt"), "name")?;

        let args: &[std::ffi::OsString] =
            &["petname".into(), "--dir".into(), dir.path().into(), "--words=3".into()];
        let cli = super::Cli::parse_from(args);
        assert_eq!(run_and_capture(cli), "adverb-adjective-name\n");
        Ok(())
    }

    #[test]
    fn option_lists() {
        let cli = super::Cli::parse_from(["petname", "--lists=large"]);
        assert!(!run_and_capture(cli).is_empty());
    }

    #[test]
    fn compat_complexity() {
        let cli = super::Cli::parse_from(["petname", "--complexity=2"]);
        assert!(!run_and_capture(cli).is_empty());
    }

    #[test]
    fn option_alliterate() {
        let cli = super::Cli::parse_from(["petname", "--alliterate", "--words=3"]);
        let first_letters: std::collections::HashSet<char> =
            run_and_capture(cli).split('-').map(|word| word.chars().next().unwrap()).collect();
        assert_eq!(first_letters.len(), 1);
    }

    #[test]
    fn option_alliterate_with() {
        let cli = super::Cli::parse_from(["petname", "--alliterate-with=a", "--words=3"]);
        let first_letters: std::collections::HashSet<char> =
            run_and_capture(cli).split('-').map(|word| word.chars().next().unwrap()).collect();
        assert_eq!(first_letters, ['a'].into());
    }

    #[test]
    fn compat_ubuntu() {
        let cli = super::Cli::parse_from(["petname", "--ubuntu", "--words=3"]);
        let first_letters: std::collections::HashSet<char> =
            run_and_capture(cli).split('-').map(|word| word.chars().next().unwrap()).collect();
        assert_eq!(first_letters.len(), 1);
    }

    #[test]
    fn option_seed() {
        let cli = super::Cli::parse_from(["petname", "--seed=12345", "--words=3"]);
        assert_eq!(run_and_capture(cli), "meaningfully-enthralled-vendace\n");
    }

    #[cfg(feature = "lang-turkish")]
    #[test]
    fn option_language_turkish() {
        let cli = super::Cli::parse_from(["petname", "--language=turkish", "--words=3"]);
        assert_eq!(run_and_capture(cli).split('-').count(), 3);
    }

    #[cfg(feature = "lang-turkish")]
    #[test]
    fn turkish_rejects_alliteration() {
        let cli = super::Cli::parse_from(["petname", "--language=turkish", "--alliterate"]);
        let mut out = Vec::new();
        assert!(super::run(cli, &mut out).is_err());
    }
}
