#![warn(clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated)]

#[cfg(test)]
mod test;

// =======================================================
// Imports
// =======================================================

use anyhow::{Context, Result, bail};
use chrono::Local;
use clap::{Arg, Command, crate_description, crate_version, parser::ArgMatches};
use env_logger::Builder;
use log::{LevelFilter, debug, warn};
use regex::Regex;
use serde::Serialize;
use std::cmp::{Ordering, Reverse};
use std::collections::{BinaryHeap, HashSet};
use std::env::set_current_dir;
use std::ffi::OsString;
use std::fmt::Debug;
use std::fs::{Metadata, canonicalize};
use std::io::Write;
use std::str::FromStr;
use std::time::SystemTime;
use walkdir::{DirEntry, WalkDir};

// =======================================================
// Defaults
// =======================================================

const DEFAULT_DIRECTORIES: bool = false;
const DEFAULT_LOGLEVEL: LevelFilter = LevelFilter::Info;
const DEFAULT_NUMBER: &str = "10";
const DEFAULT_OUTPUT_COMBO: &str = "ip"; // By default we output file ISO timestamp and path
const DEFAULT_RETURN: &str = "youngest";
const DEFAULT_TIME_ATTRIBUTE: TimeAttribute = TimeAttribute::Modified;
const DEFAULT_WEIRD: bool = false;
const DEFAULT_XDEV: bool = true;

// =======================================================
// Types and conversions
// =======================================================

// Used when deciding on what file attribute to order on
#[derive(Debug, PartialEq)]
enum TimeAttribute {
    Accessed,
    Created,
    Modified,
    Unset,
}
impl FromStr for TimeAttribute {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "accessed" => Ok(Self::Accessed),
            "created" => Ok(Self::Created),
            "modified" => Ok(Self::Modified),
            "" => Ok(Self::Unset),
            _ => bail!("Invalid time attribute '{s}'"),
        }
    }
}

// Used for handling options regarding result output
#[derive(Debug, Eq, Hash, PartialEq)]
enum OutputItem {
    Nulls,
    Json,
    Path,
    SizeBytes,
    TimeISO,
    TimeUnix,
}
#[derive(Eq, Debug, PartialEq)]
struct OutputCombo {
    output_items: HashSet<OutputItem>,
}
impl Default for OutputCombo {
    fn default() -> Self {
        Self::from_str(DEFAULT_OUTPUT_COMBO).expect("Invalid default")
    }
}
impl FromStr for OutputCombo {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut hs = HashSet::default();
        let letters = s.chars();
        for letter in letters {
            let item = match letter {
                '0' => OutputItem::Nulls,
                'b' => OutputItem::SizeBytes,
                'p' => OutputItem::Path,
                'i' => OutputItem::TimeISO,
                'j' => OutputItem::Json,
                'u' => OutputItem::TimeUnix,
                _ => bail!("Unknown output flag '{letter}'"),
            };
            hs.insert(item);
        }
        Ok(Self { output_items: hs })
    }
}
impl OutputCombo {
    fn contains(&self, value: &OutputItem) -> bool {
        self.output_items.contains(value)
    }
}

// Handling of: Are we looking for newest or oldest files?
#[derive(Debug, PartialEq)]
enum ResultOrder {
    Oldest,
    Youngest,
    Largest,
    Smallest,
}
impl FromStr for ResultOrder {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "oldest" => Ok(Self::Oldest),
            "youngest" => Ok(Self::Youngest),
            "largest" => Ok(Self::Largest),
            "smallest" => Ok(Self::Smallest),
            _ => bail!("Unknown result order '{s}'"),
        }
    }
}

// Handling of configuration
#[derive(Debug)]
#[allow(clippy::struct_excessive_bools)]
struct Cfg {
    directories: bool,
    exclude: Option<Regex>,
    include: Option<Regex>,
    number: usize,
    output_combo: OutputCombo,
    result_order: ResultOrder,
    startdirs: Vec<OsString>,
    time_attribute: TimeAttribute,
    unicode_supported: bool,
    weird: bool,
    xdev: bool,
}

trait OrderingSystemTime: Ord + From<SystemTime> {
    fn from_timeattr(st: SystemTime) -> Self;
}

// For determining the oldest files, we need a heap where the
// newest (highest) item is removed by pop(), so that the smallest
// (oldest) items are kept.
// So we use a max-heap which in Rust is done with BinaryHeap (which
// per default is a max-heap).
#[derive(Debug, Eq, PartialEq, PartialOrd, Ord)]
struct SystemTimeForOldest {
    timeattr: SystemTime,
}
impl OrderingSystemTime for SystemTimeForOldest {
    fn from_timeattr(st: SystemTime) -> Self {
        Self { timeattr: st }
    }
}
impl From<SystemTime> for SystemTimeForOldest {
    fn from(st: SystemTime) -> Self {
        Self { timeattr: st }
    }
}
impl From<SystemTimeForOldest> for SystemTime {
    fn from(s: SystemTimeForOldest) -> Self {
        s.timeattr
    }
}

// For determining the youngest files, we need a heap where the
// oldest (with lowest time value) item is removed by pop(), so
// that the largest (youngest) items are kept.
// So we use a min-heap which in Rust is done with BinaryHeap (which
// per default is a max-heap) with Reverse elements.
#[derive(Debug, Eq, PartialEq, PartialOrd, Ord)]
struct SystemTimeForYoungest {
    timeattr: Reverse<SystemTime>,
}
impl OrderingSystemTime for SystemTimeForYoungest {
    fn from_timeattr(st: SystemTime) -> Self {
        Self {
            timeattr: Reverse(st),
        }
    }
}
impl From<SystemTime> for SystemTimeForYoungest {
    fn from(st: SystemTime) -> Self {
        Self {
            timeattr: Reverse(st),
        }
    }
}
impl From<SystemTimeForYoungest> for SystemTime {
    fn from(s: SystemTimeForYoungest) -> Self {
        s.timeattr.0
    }
}

type FileSize = u64;
trait OrderingFileSize: Ord + From<FileSize> {
    fn from_filesize(s: FileSize) -> Self;
}

// For determining the largest files, we need a heap where the
// smallest item is removed by pop(), so that the largest items are kept.
// So we use a min-heap which in Rust is done with BinaryHeap (which
// per default is a max-heap) with Reverse elements.
#[derive(Debug, Eq, PartialEq, PartialOrd, Ord)]
struct FileSizeForLargest {
    filesize: Reverse<FileSize>,
}
impl OrderingFileSize for FileSizeForLargest {
    fn from_filesize(s: FileSize) -> Self {
        Self {
            filesize: Reverse(s),
        }
    }
}
impl From<FileSize> for FileSizeForLargest {
    fn from(s: FileSize) -> Self {
        Self {
            filesize: Reverse(s),
        }
    }
}
impl From<FileSizeForLargest> for FileSize {
    fn from(s: FileSizeForLargest) -> Self {
        s.filesize.0
    }
}

// For determining the smallest files, we need a heap where the
// largest item is removed by pop(), so that the largest items are kept.
// So we use a max-heap which in Rust is BinaryHeap.
#[derive(Debug, Eq, PartialEq, PartialOrd, Ord)]
struct FileSizeForSmallest {
    filesize: FileSize,
}
impl OrderingFileSize for FileSizeForSmallest {
    fn from_filesize(filesize: FileSize) -> Self {
        Self { filesize }
    }
}
impl From<FileSize> for FileSizeForSmallest {
    fn from(filesize: FileSize) -> Self {
        Self { filesize }
    }
}
impl From<FileSizeForSmallest> for FileSize {
    fn from(s: FileSizeForSmallest) -> Self {
        s.filesize
    }
}

// Structure recording directory entry information in a way where
// it sorts it self against other TimeOrderedScoreBoardEntry data as being newer/older,
// based on which OrderingSystemTime type is being used to instantiate
// the struct.
#[derive(Debug)]
struct TimeOrderedScoreBoardEntry<T: OrderingSystemTime> {
    timeattr: T,
    path: OsString,
    size_b: FileSize,
}
impl<T: OrderingSystemTime> PartialOrd for TimeOrderedScoreBoardEntry<T> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl<T: OrderingSystemTime> Ord for TimeOrderedScoreBoardEntry<T> {
    fn cmp(&self, other: &Self) -> Ordering {
        self.timeattr.cmp(&other.timeattr)
    }
}
impl<T: OrderingSystemTime> PartialEq for TimeOrderedScoreBoardEntry<T> {
    fn eq(&self, other: &Self) -> bool {
        self.timeattr == other.timeattr
    }
}
impl<T: OrderingSystemTime> Eq for TimeOrderedScoreBoardEntry<T> {}
type TimeOrderedScoreBoard<T> = BinaryHeap<TimeOrderedScoreBoardEntry<T>>;

// Structure recording directory entry information in a way where
// it sorts it self against other ScoreBoardEntry data as being smaller/larger,
// based on which OrderingFileSize type is being used to instantiate
// the struct.
#[derive(Debug)]
struct SizeOrderedScoreBoardEntry<T: OrderingFileSize> {
    timeattr: SystemTime,
    path: OsString,
    size_b: T,
}
impl<T: OrderingFileSize> PartialOrd for SizeOrderedScoreBoardEntry<T> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl<T: OrderingFileSize> Ord for SizeOrderedScoreBoardEntry<T> {
    fn cmp(&self, other: &Self) -> Ordering {
        self.size_b.cmp(&other.size_b)
    }
}
impl<T: OrderingFileSize> PartialEq for SizeOrderedScoreBoardEntry<T> {
    fn eq(&self, other: &Self) -> bool {
        self.size_b == other.size_b
    }
}
impl<T: OrderingFileSize> Eq for SizeOrderedScoreBoardEntry<T> {}
type SizeOrderedScoreBoard<T> = BinaryHeap<SizeOrderedScoreBoardEntry<T>>;

// Structure recording directory entry information which is not
// to be sorted on, but which is to be printed.
#[derive(Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
struct OutputScoreBoardEntry {
    timeattr: SystemTime,
    path: String,
    size_b: FileSize,
}
impl<T: OrderingSystemTime> From<TimeOrderedScoreBoardEntry<T>> for OutputScoreBoardEntry
where
    SystemTime: From<T>,
{
    fn from(se: TimeOrderedScoreBoardEntry<T>) -> Self {
        Self {
            timeattr: se.timeattr.into(),
            path: se.path.to_string_lossy().to_string(),
            size_b: se.size_b,
        }
    }
}
impl<T: OrderingFileSize> From<SizeOrderedScoreBoardEntry<T>> for OutputScoreBoardEntry
where
    FileSize: From<T>,
{
    fn from(se: SizeOrderedScoreBoardEntry<T>) -> Self {
        Self {
            timeattr: se.timeattr,
            path: se.path.to_string_lossy().to_string(),
            size_b: se.size_b.into(),
        }
    }
}

#[derive(Debug, Serialize)]
struct OutputScoreBoard(Vec<OutputScoreBoardEntry>);
impl<T: OrderingSystemTime> From<TimeOrderedScoreBoard<T>> for OutputScoreBoard
where
    SystemTime: From<T>,
{
    fn from(mut sb: TimeOrderedScoreBoard<T>) -> Self {
        let mut out = Vec::new();
        let mut entry_out: OutputScoreBoardEntry;
        while let Some(entry) = sb.pop() {
            entry_out = entry.into();
            out.push(entry_out);
        }
        Self(out)
    }
}
impl<T: OrderingFileSize> From<SizeOrderedScoreBoard<T>> for OutputScoreBoard
where
    FileSize: From<T>,
{
    fn from(mut sb: SizeOrderedScoreBoard<T>) -> Self {
        let mut out = Vec::new();
        let mut entry_out: OutputScoreBoardEntry;
        while let Some(entry) = sb.pop() {
            entry_out = entry.into();
            out.push(entry_out);
        }
        Self(out)
    }
}
impl IntoIterator for OutputScoreBoard {
    type Item = OutputScoreBoardEntry;
    type IntoIter = std::vec::IntoIter<Self::Item>;
    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

// =======================================================
// Program initialization / boiler plate
// =======================================================

// Catch-all for errors which have not otherwise "manually" been caught
fn main() {
    if let Err(e) = begin() {
        bailout(&e.to_string());
    }
}

// Args handling, configuration setup, sanity checks, and initialization
// of program logic.
fn begin() -> Result<()> {
    if std::env::var("RUST_LOG").is_err() {
        let mut builder = Builder::from_default_env();
        builder.filter(None, DEFAULT_LOGLEVEL).init();
    } else {
        env_logger::init();
    }

    let args = parse_args();

    // unwrap()s are OK here, because Clap has ensured we get a value

    let exclude = match args.get_one::<String>("exclude") {
        Some(exclude_str) => Some(
            Regex::new(exclude_str)
                .context("Invalid regular expression for the --exclude argument")?,
        ),
        None => None,
    };

    let include = match args.get_one::<String>("include") {
        Some(include_str) => Some(
            Regex::new(include_str)
                .context("Invalid regular expression for the --include argument")?,
        ),
        None => None,
    };

    let output_combo_str = args.get_one::<String>("output").unwrap().as_str();
    let output_combo = OutputCombo::from_str(output_combo_str)?;

    let result_order_str = args.get_one::<String>("return").unwrap().as_str();
    let result_order = ResultOrder::from_str(result_order_str)?;

    let start_strings = args.get_many::<String>("startdirs").unwrap();
    let startdirs: Vec<OsString> = start_strings.map(|s| (*s).clone().into()).collect();

    let time_attribute_str = args.get_one::<String>("attribute").unwrap().as_str();
    let time_attribute = TimeAttribute::from_str(time_attribute_str)?;

    let mut cfg = Cfg {
        // unwrap()s are OK here, because Clap has ensured we get a value
        directories: *args
            .get_one::<bool>("directories")
            .unwrap_or(&DEFAULT_DIRECTORIES),
        exclude,
        include,
        number: *args.get_one::<usize>("number").unwrap(),
        output_combo,
        result_order,
        startdirs,
        time_attribute,
        weird: *args.get_one::<bool>("weird").unwrap_or(&DEFAULT_WEIRD),
        unicode_supported: false,
        xdev: *args.get_one::<bool>("xdev").unwrap_or(&DEFAULT_XDEV),
    };

    sanity_check(&cfg)?;
    // We are past sanity check, so if cfg.time_attribute is None, we can set
    // it to the default value.
    if cfg.time_attribute == TimeAttribute::Unset {
        cfg.time_attribute = DEFAULT_TIME_ATTRIBUTE;
    }
    debug!("cfg: {cfg:?}");

    // Determine, if we can use fancy characters in output
    cfg.unicode_supported = supports_unicode::on(supports_unicode::Stream::Stdout);

    let os = build_output_scoreboard(&cfg)?;
    let out_bytes: Vec<u8> = build_output_bytes(&cfg, os)?;
    let mut stdout = std::io::stdout().lock();
    stdout.write_all(&out_bytes)?;

    Ok(())
}

fn build_output_scoreboard(cfg: &Cfg) -> Result<OutputScoreBoard> {
    let os = match cfg.result_order {
        ResultOrder::Oldest => recurse_for_time::<SystemTimeForOldest>(cfg)?.into(),
        ResultOrder::Youngest => recurse_for_time::<SystemTimeForYoungest>(cfg)?.into(),
        ResultOrder::Largest => recurse_for_size::<FileSizeForLargest>(cfg)?.into(),
        ResultOrder::Smallest => recurse_for_size::<FileSizeForSmallest>(cfg)?.into(),
    };
    Ok(os)
}

// ======================================================
// Program logic.
// ======================================================

fn recurse_for_time<T: OrderingSystemTime + Debug>(
    cfg: &Cfg,
) -> anyhow::Result<TimeOrderedScoreBoard<T>, anyhow::Error>
where
    SystemTime: From<T>,
{
    let mut scoreboard = TimeOrderedScoreBoard::<T>::with_capacity(cfg.number);
    let mut access_problem_seen = false;

    for startdir in &cfg.startdirs {
        for entry in WalkDir::new(startdir)
            .same_file_system(cfg.xdev)
            .into_iter()
            .filter_map(|r| r.map_err(|_| access_problem_seen = true).ok())
            .filter(|e| pathfilter(cfg, e))
        {
            if entry.path().is_dir() && !cfg.directories {
                debug!(
                    "Skipping path' {}' because it is a directory",
                    entry.path().display()
                );
                continue;
            }
            match entry.metadata() {
                Ok(metadata) => {
                    let filetime = get_time_attribute(cfg, &metadata)?;
                    let to_add = TimeOrderedScoreBoardEntry::<T> {
                        timeattr: OrderingSystemTime::from_timeattr(filetime),
                        path: entry.path().as_os_str().to_os_string().clone(),
                        size_b: metadata.len(),
                    };

                    debug!("Adding to scoreboard: {to_add:?}");
                    scoreboard.push(to_add);
                    if scoreboard.len() > cfg.number {
                        let removed = scoreboard.pop();
                        debug!("Removed from scoreboard: {removed:?}");
                    }
                }
                Err(e) => {
                    debug!(
                        "Could not get metadata for path '{}': {e}",
                        entry.path().display()
                    );
                }
            }
        }
    }
    if access_problem_seen {
        warn!("At least one inaccessible directory was skipped");
    }
    Ok(scoreboard)
}

fn recurse_for_size<T: OrderingFileSize + Debug>(
    cfg: &Cfg,
) -> anyhow::Result<SizeOrderedScoreBoard<T>, anyhow::Error>
where
    FileSize: From<T>,
{
    let mut scoreboard = SizeOrderedScoreBoard::<T>::with_capacity(cfg.number);
    let mut access_problem_seen = false;

    for startdir in &cfg.startdirs {
        for entry in WalkDir::new(startdir)
            .same_file_system(cfg.xdev)
            .into_iter()
            .filter_map(|r| r.map_err(|_| access_problem_seen = true).ok())
        {
            if entry.path().is_dir() && !cfg.directories {
                debug!(
                    "Skipping path' {}' because it is a directory",
                    entry.path().display()
                );
                continue;
            }
            match entry.metadata() {
                Ok(metadata) => {
                    let filetime = get_time_attribute(cfg, &metadata)?;
                    let to_add = SizeOrderedScoreBoardEntry::<T> {
                        timeattr: filetime,
                        path: entry.path().as_os_str().to_os_string().clone(),
                        size_b: OrderingFileSize::from_filesize(metadata.len()),
                    };

                    debug!("Adding to scoreboard: {to_add:?}");
                    scoreboard.push(to_add);
                    if scoreboard.len() > cfg.number {
                        let removed = scoreboard.pop();
                        debug!("Removed from scoreboard: {removed:?}");
                    }
                }
                Err(e) => {
                    debug!(
                        "Could not get metadata for path '{}': {e}",
                        entry.path().display()
                    );
                }
            }
        }
    }
    if access_problem_seen {
        warn!("At least one inaccessible directory was skipped");
    }
    Ok(scoreboard)
}

fn pathfilter(cfg: &Cfg, entry: &DirEntry) -> bool {
    if let Some(exclude_regex) = &cfg.exclude
        && exclude_regex.is_match(&entry.path().as_os_str().to_string_lossy())
    {
        return false;
    }
    if let Some(include_regex) = &cfg.include
        && !include_regex.is_match(&entry.path().as_os_str().to_string_lossy())
    {
        return false;
    }
    true
}

fn build_output_bytes(cfg: &Cfg, sb: OutputScoreBoard) -> Result<Vec<u8>> {
    if cfg.output_combo.contains(&OutputItem::Json) {
        let json_data = serde_json::to_vec(&sb)?;
        return Ok(json_data);
    }

    let mut out_bytes: Vec<u8> = Vec::new();
    for sb_entry in sb {
        out_bytes.append(&mut get_output_record(cfg, sb_entry));
    }
    Ok(out_bytes)
}

fn get_output_record(cfg: &Cfg, osb_entry: OutputScoreBoardEntry) -> Vec<u8> {
    if cfg.output_combo.contains(&OutputItem::Nulls) {
        let mut out_bytes = osb_entry.path.into_bytes();
        out_bytes.push(b'\0');
        return out_bytes;
    }

    let mut parts = Vec::new();
    if cfg.output_combo.contains(&OutputItem::SizeBytes) {
        parts.push(osb_entry.size_b.to_string());
    }
    if cfg.output_combo.contains(&OutputItem::TimeISO) {
        let dt: chrono::DateTime<Local> = osb_entry.timeattr.into();
        parts.push(format!("{}", dt.format("%+")));
    }
    if cfg.output_combo.contains(&OutputItem::TimeUnix) {
        let dur = osb_entry
            .timeattr
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        parts.push(dur.to_string());
    }
    if cfg.output_combo.contains(&OutputItem::Path) {
        // We don't want newlines in filenames in output
        parts.push(terminal_friendly_path(cfg, &osb_entry.path));
    }
    let out_string = parts.join(" ") + "\n";
    out_string.into_bytes()
}

// =================================================
// Utility functions
// =================================================

fn sanity_check(cfg: &Cfg) -> Result<()> {
    if cfg.number == 0 && !cfg.weird {
        bail!("Number of returned files cannot be 0");
    }
    if cfg.directories && !cfg.weird {
        if cfg.time_attribute == TimeAttribute::Accessed {
            bail!(
                "Sorting on access time and including directory-type directory entries is likely a mistake"
            );
        }
        if (cfg.result_order == ResultOrder::Largest) || (cfg.result_order == ResultOrder::Smallest)
        {
            bail!(
                "Sorting on size and including directory-type directory entries is likely a mistake"
            );
        }
    }
    if cfg.time_attribute != TimeAttribute::Unset
        && (cfg.result_order == ResultOrder::Largest || cfg.result_order == ResultOrder::Smallest)
    {
        bail!("Refusing to evaluate file time attributes when sorting on file size");
    }
    if cfg.output_combo.output_items.is_empty() && !cfg.weird {
        bail!("It is likely a mistake to only want blank output lines");
    }
    if cfg.output_combo.output_items.len() > 1
        && cfg.output_combo.output_items.contains(&OutputItem::Nulls)
    {
        bail!("The 0 output option can not be combined with other output items");
    }
    if !cfg.weird {
        let mut canonical_startpaths = Vec::new();
        debug!("startdirs: {:?}", &cfg.startdirs);
        for startpath in &cfg.startdirs {
            let canonpath = canonicalize(startpath)?;
            debug!("canonpath seen: {}", canonpath.display());
            canonical_startpaths.push(canonpath);
        }
        let unique: HashSet<_> = canonical_startpaths.iter().cloned().collect();
        debug!("unique canonical start dirs: {unique:?}");
        if unique.len() != canonical_startpaths.len() {
            bail!("The indicated start directories are not unique");
        }
    }
    for startdir in &cfg.startdirs {
        if set_current_dir(startdir).is_err() {
            bail!(
                "Directory '{}' is inaccessible, or does not exist",
                startdir.to_string_lossy()
            );
        }
    }
    Ok(())
}

fn get_time_attribute(cfg: &Cfg, metadata: &Metadata) -> Result<SystemTime> {
    let res = match cfg.time_attribute {
        TimeAttribute::Accessed => metadata
            .accessed()
            .context("Failed getting accessed time")?,
        TimeAttribute::Created => metadata.created().context("Failed getting created time")?,
        TimeAttribute::Modified => metadata
            .modified()
            .context("Failed getting modified time")?,
        TimeAttribute::Unset => bail!("Impossible TimeAttribute value(?!)"),
    };
    Ok(res)
}

fn bailout(msg: &(impl Into<String> + std::fmt::Display)) {
    eprintln!("Error: {msg}");
    std::process::exit(1);
}

// This string sanetizer is used for output wich is neither
// null-terminated ("print0") nor JSON-serialized.
// In this situation we know that the path will be the last
// element printed on each line. So we don't handle spaces in a
// special way, because the path "record" will be easy to pick
// out using -- for example -- "cut -d ' ' -f 2-", if need be.
fn terminal_friendly_path(cfg: &Cfg, pathstr: &str) -> String {
    pathstr
        .chars()
        .flat_map(|c| {
            match c {
                // control characters
                '\x00'..='\x1F' => {
                    if cfg.unicode_supported {
                        // It's OK to unwrap() here, because the set of possible
                        // input is rather limited, and it should be possible
                        // to generate Unicode from any of the chars in \x00..\x1F:
                        vec![char::from_u32(0x2400 + c as u32).unwrap()]
                    } else {
                        std::ascii::escape_default(c as u8)
                            .map(char::from)
                            .collect::<Vec<char>>()
                    }
                }
                '\x7f' => {
                    // The odd DEL char
                    if cfg.unicode_supported {
                        vec!['\u{2421}']
                    } else {
                        "^?".chars().collect()
                    }
                }
                // All else left untouched
                _ => vec![c],
            }
        })
        .collect()
}

fn parse_args() -> ArgMatches {
    let cmd = Command::new(env!("CARGO_CRATE_NAME"))
        .about(crate_description!().to_owned() + ".")
        .version(crate_version!())
        .arg(
            Arg::new("attribute")
                .short('a')
                .long("attribute")
                .default_value("")
                .value_parser(clap::builder::PossibleValuesParser::new([
                    "", "accessed", "created", "modified",
                ]))
                .help("Which time attribute to sort on; default is 'modified'")
        )
        .arg(
            Arg::new("directories")
                .short('d')
                .long("directories")
                .num_args(0)
                .help("Evaluate also directories(not only files)")
        )
        .arg(
            Arg::new("number")
                .short('n')
                .long("number")
                .default_value(DEFAULT_NUMBER)
                .value_parser(clap::value_parser!(usize))
                .help("Number of directory entries to return")
        )
        .arg(
            Arg::new("exclude")
                .short('e')
                .long("exclude")
                .help("A regular expression which the path must NOT match in order for the file to be evaluated; the regular expression dialect is that of the Rust 'regex' crate")
        )
        .arg(
            Arg::new("include")
                .short('i')
                .long("include")
                .help("A regular expression which the path must match in order for the file to be evaluated; the regular expression dialect is that of the Rust 'regex' crate")
        )
        .arg(
            Arg::new("output")
                .short('o')
                .long("output")
                .default_value(DEFAULT_OUTPUT_COMBO)
                .help("What to output about each file: i=ISO time, j=json, p=human readable path, u=unixtime, b=size(bytes), 0=print0
'0' cannot be combined with other output elements; it means that output should be handled like the find utility does with its -print0 argument: Record separator is the null character instead of newline
'j' means JSON and cannot be combined with other output elements; resulting JSON will contain time, path, and size
'p' will replace problematic charaters (newlines, the bell char, etc) in paths with the Unicode control pictures or caret escaped charaters, depending on the capabilities of the environment"
                )
        )
        .arg(
            Arg::new("return")
                .short('r')
                .long("return")
                .default_value(DEFAULT_RETURN)
                .value_parser(clap::builder::PossibleValuesParser::new([
                    "largest", "oldest", "smallest", "youngest",
                ]))
                .help("What to sort on: oldest, youngest, largest, or smallest")
        )
        .arg(
            Arg::new("weird")
                .short('w')
                .long("weird")
                .num_args(0)
                .help("Accept weird option combinations")
        )
        .arg(
            Arg::new("xdev")
                .short('x')
                .long("xdev")
                .num_args(0)
                .help("Do not descend directories on other filesystems")
        )
        .arg(
            Arg::new("startdirs")
                .required(true)
                .num_args(1..)
                .help("Which directory to start in; multiple start directories may be stated, separated by spaces")
        );

    cmd.get_matches()
}
