#![feature(trait_alias)]
use std::collections::HashMap;
use std::fmt;
use std::fs::{self, File};
use std::io::{BufReader, Read, Seek};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};

use anyhow::{anyhow, Context, Error, Result};
use chrono::{DateTime, Duration, TimeZone, Utc};
use colored::Colorize;
use log::debug;
use regex::Regex;
use rustyline::Editor;
use structopt::StructOpt;
use walkdir::{DirEntry, WalkDir};

use evtclib::raw::parser::PartialEvtc;
use evtclib::{Boss, Event, EventKind, Log};

mod fexpr;
mod filters;
use filters::{log::LogFilter, Inclusion};
mod guilds;
mod logger;
mod output;
use output::sorting::Sorting;
mod paths;
mod playerclass;
use playerclass::PlayerClass;

/// Application name, as it should be used in configuration directory paths.
const APP_NAME: &str = "raidgrep";

/// Process exit code for when everything went right.
const RETCODE_SUCCESS: i32 = 0;
/// Process exit code for when no results are found.
const RETCODE_NO_RESULTS: i32 = 1;
/// Process exit code for when an error occurred.
const RETCODE_ERROR: i32 = 2;

/// A program that allows you to search through all your evtc logs for specific people.
///
/// raidgrep supports different predicates that determine whether a log is included or not.
/// Predicates start with a - and optionally take an argument. Predicates can be combined with
/// "and", "or" and "not", and predicates that operate on single players (instead of logs) have to
/// be within an "all(player: ...)" or "any(player: ...)" construct.
///
/// PREDICATES:
///     -character REGEX        True if the character name matches the regex.
///     -account REGEX          True if the account name matches the regex.
///     -name REGEX             True if either character or account name match.
///     -class CLASSES          True if the player has one of the listed classes.
///
///     -success                Only include successful logs.
///     -wipe                   Only include failed logs.
///     -outcome OUTCOMES       Only include logs with the given outcomes.
///     -weekday WEEKDAYS       Only include logs from the given weekdays.
///     -before DATE            Only include logs from before the given date.
///     -after DATE             Only include logs from after the given date.
///     -boss BOSSES            Only include logs from the given bosses.
///     -cm                     Only include logs with challenge mote enabled.
///     -player REGEX           Shorthand to check if any player in the log has the given name.
///     -include                Always evaluates to including the log.
///     -exclude                Always evaluates to excluding the log.
///
/// BOSS NAMES:
///     The following names can be used with the -boss filter:
///       vg, gorseval, sabetha, slothasor, matthias, kc, xera, cairn,
///       mo, samarog, deimos, desmina, dhuum, ca, largos, qadim,
///       adina, sabir, qadimp, skorvald, artsariiv, arkk, mama, siax,
///       ensolyss, icebrood, fraenir, kodans, boneskinner, whisper.
///     Names can also be comma separated.
#[derive(StructOpt, Debug)]
#[structopt(verbatim_doc_comment)]
pub struct Opt {
    /// Path to the folder with logs.
    #[structopt(short = "d", long = "dir", default_value = ".", parse(from_os_str))]
    path: PathBuf,

    /// Check the given file only.
    #[structopt(
        short = "c",
        long = "check",
        value_name = "file",
        conflicts_with_all = &["path", "repl"],
    )]
    check: Option<PathBuf>,

    /// Only show the name of matching files.
    #[structopt(short = "l", long = "files-with-matches")]
    file_name_only: bool,

    /// Disable colored output.
    #[structopt(long = "no-color")]
    no_color: bool,

    /// Sort the output.
    ///
    /// Valid sorting fields are date, boss, cm and outcome. Prefix the field with ~ to sort in
    /// descending order.
    #[structopt(short = "s", long = "sort")]
    sorting: Option<Sorting>,

    /// Print more debugging information to stderr.
    #[structopt(long = "debug")]
    debug: bool,

    /// Load guild information from the API.
    ///
    /// Loading guild information requires network access and slows down the program considerably,
    /// so this is disabled by default.
    #[structopt(long = "guilds")]
    guilds: bool,

    /// Run the REPL.
    ///
    /// The REPL will allow you to keep entering queries which are being searched by raidgrep,
    /// until you manually exit with Crtl+C.
    #[structopt(long)]
    repl: bool,

    /// The filter expression, see PREDICATES for more information.
    expression: Vec<String>,
}

impl Opt {
    fn build_filter(&self) -> Result<Box<dyn LogFilter>> {
        // As a shortcut, we allow only the regular expression to be given, to retain the behaviour
        // before the filter changes.

        // Special characters that when present will prevent the filter to be interpreted as a
        // regex. This is to ensure that errors are properly reported on invalid filterlines
        // instead of being swallowed because the filter was taken as a (valid) regex:
        const SPECIAL_CHARS: &[char] = &['-', '(', ')', ':', '<', '>', '='];

        if self.expression.len() == 1 {
            let line = &self.expression[0];
            let maybe_filter = build_filter(line);
            if maybe_filter.is_err() && !line.contains(SPECIAL_CHARS) {
                let maybe_regex = Regex::new(line);
                if let Ok(rgx) = maybe_regex {
                    let filter = filters::player::any(
                        filters::player::account(rgx.clone()) | filters::player::character(rgx),
                    );
                    return Ok(filter);
                }
            }
            return Ok(maybe_filter?);
        }

        let expr_string = fexpr::requote(&self.expression);
        Ok(build_filter(&expr_string)?)
    }
}

/// A log that matches the search criteria.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LogResult {
    /// The path to the log file.
    log_file: PathBuf,
    /// The time of the recording.
    time: DateTime<Utc>,
    /// The duration of the fight.
    duration: Duration,
    /// The boss.
    boss: Option<Boss>,
    /// A vector of all participating players.
    players: Vec<Player>,
    /// The outcome of the fight.
    outcome: FightOutcome,
    /// Whether the fight had the Challenge Mote turned on.
    is_cm: bool,
}

/// A player.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Player {
    /// Account name of the player.
    account_name: String,
    /// Character name of the player.
    character_name: String,
    /// Profession or elite specialization.
    profession: PlayerClass,
    /// Subsquad that the player was in.
    subgroup: u8,
    /// Guild ID, ready for API consumption.
    guild_id: Option<String>,
}

impl PartialOrd for Player {
    fn partial_cmp(&self, other: &Player) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Player {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        (self.subgroup, &self.account_name, &self.character_name).cmp(&(
            other.subgroup,
            &other.account_name,
            &other.character_name,
        ))
    }
}

/// Outcome of the fight.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum FightOutcome {
    Success,
    Wipe,
}

/// A stripped version of [`LogResult`][LogResult] that is available early in the parsing process.
///
/// This can be used by filters to filter out logs early, before they will be fully parsed.
#[derive(Debug, Clone)]
pub struct EarlyLogResult {
    /// The path to the log file.
    log_file: PathBuf,
    /// The partially parsed evtc.
    evtc: PartialEvtc,
}

impl FromStr for FightOutcome {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "success" | "kill" => Ok(FightOutcome::Success),
            "wipe" | "fail" => Ok(FightOutcome::Wipe),
            _ => Err("Must be success or wipe"),
        }
    }
}

enum ZipWrapper<R: Read + Seek> {
    Raw(Option<R>),
    Zipped(zip::ZipArchive<R>),
}

impl<R: Read + Seek> ZipWrapper<R> {
    pub fn raw(input: R) -> Self {
        ZipWrapper::Raw(Some(input))
    }

    pub fn zipped(input: R) -> Self {
        ZipWrapper::Zipped(zip::ZipArchive::new(input).unwrap())
    }

    pub fn get_stream<'a>(&'a mut self) -> Box<(dyn Read + 'a)> {
        match *self {
            ZipWrapper::Raw(ref mut o) => Box::new(o.take().unwrap()),
            ZipWrapper::Zipped(ref mut z) => Box::new(z.by_index(0).unwrap()),
        }
    }
}

#[derive(Clone, Debug)]
struct InputError {
    line: String,
    location: usize,
    msg: String,
}

impl fmt::Display for InputError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let prefix = "Input:";
        writeln!(f, "{} {}", prefix.yellow(), self.line)?;
        let prefix_len = prefix.len() + self.location;
        writeln!(f, "{}{}", " ".repeat(prefix_len), " ^-".red())?;
        write!(f, "{}: {}", "Error".red(), self.msg)?;
        Ok(())
    }
}

impl std::error::Error for InputError {}

/// A flag that indicates whether the current search should be interrupted because the user pressed
/// Crtl-C.
///
/// This flag can be set to `true` by signal handlers in order to exit the search as early as
/// possible. Note that the search won't be exited immediately, as any logs currently in the
/// process of being parsed will finish to do so.
///
/// The flag is automatically reset to `false` by the repl.
static INTERRUPTED: AtomicBool = AtomicBool::new(false);

fn main() {
    let result = run();
    match result {
        Ok(retcode) => std::process::exit(retcode),
        Err(err) => {
            display_error(&err);
            std::process::exit(RETCODE_ERROR);
        }
    }
}

fn display_error(err: &Error) {
    if let Some(err) = err.downcast_ref::<InputError>() {
        eprintln!("{}", err);
    } else {
        eprintln!("{}: {}", "Error".red(), err);
    }
}

fn run() -> Result<i32> {
    let opt = Opt::from_args();

    if opt.no_color {
        colored::control::set_override(false);
    }

    if opt.debug {
        logger::initialize(log::Level::Debug);
    } else {
        logger::initialize(log::Level::Info);
    }

    if opt.guilds {
        guilds::prepare_cache();
    }

    let retcode;
    if !opt.repl {
        if single(&opt)? {
            retcode = RETCODE_SUCCESS;
        } else {
            retcode = RETCODE_NO_RESULTS;
        }
    } else {
        repl(&opt)?;
        retcode = RETCODE_SUCCESS;
    }

    if opt.guilds {
        guilds::save_cache();
    }

    Ok(retcode)
}

fn single(opt: &Opt) -> Result<bool> {
    let filter = opt.build_filter()?;
    if let Some(ref path) = opt.check {
        let is_zip = path
            .file_name()
            .and_then(|f| f.to_str())
            .map(is_zip_file_name)
            .unwrap_or(false);
        search_file(path, is_zip, &*filter).map(|r| r.is_some())
    } else {
        grep(&opt, &*filter)
    }
}

fn repl(opt: &Opt) -> Result<()> {
    ctrlc::set_handler(|| INTERRUPTED.store(true, Ordering::Relaxed))
        .expect("Could not set interrupt hanlder");

    let mut rl = Editor::<()>::new();
    let history_path = paths::history_path();
    if history_path.is_none() {
        debug!("Could not determine the history path");
    }

    maybe_load_history(&mut rl, history_path.as_ref().map(|r| r as &Path));

    loop {
        let line = rl.readline("Query> ")?;
        rl.add_history_entry(&line);
        maybe_save_history(&rl, history_path.as_ref().map(|r| r as &Path));

        let parsed = build_filter(&line);
        INTERRUPTED.store(false, Ordering::Relaxed);
        match parsed {
            Ok(filter) => grep(&opt, &*filter).map(|_| ())?,
            Err(err) => display_error(&err),
        }
    }
}

fn maybe_load_history(rl: &mut Editor<()>, path: Option<&Path>) {
    if let Some(path) = path {
        debug!("Loading history from {:?}", path);
        if let Err(e) = rl.load_history(path) {
            debug!("Loading the history failed: {}", e);
        }
    }
}

fn maybe_save_history(rl: &Editor<()>, path: Option<&Path>) {
    let run = |path: &Path| -> Result<()> {
        debug!("Saving history to {:?}", path);
        let parent = path
            .parent()
            .ok_or_else(|| anyhow!("Path does not have a parent"))?;
        fs::create_dir_all(parent).context("Could not create directory")?;
        rl.save_history(path)?;
        Ok(())
    };
    if let Some(path) = path {
        if let Err(e) = run(path) {
            debug!("Saving the history failed: {}", e);
        }
    }
}

/// Check if the given entry represents a log file, based on the file name.
fn is_log_file(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .map(|n| n.ends_with(".evtc") || n.ends_with(".evtc.zip") || n.ends_with(".zevtc"))
        .unwrap_or(false)
}

/// Small wrapper around `fexpr::parse_logfilter` to convert the returned `Err` to be `'static'.
fn build_filter(expr_string: &str) -> Result<Box<dyn LogFilter>> {
    if expr_string.trim().is_empty() {
        return Err(anyhow!("Expected a filter to be given"));
    }
    Ok(
        fexpr::parse_logfilter(expr_string).map_err(|error| InputError {
            line: expr_string.to_string(),
            location: fexpr::location(&error),
            msg: error.to_string(),
        })?,
    )
}

/// Run the grep search with the given options.
///
/// This function returns `false` if no log has been found, and `true` if at least one log matched
/// the filter.
fn grep(opt: &Opt, filter: &dyn LogFilter) -> Result<bool> {
    let pipeline = output::build_pipeline(opt);
    let pipeline_ref = &pipeline;
    let found_something = &AtomicBool::new(false);
    let result: Result<()> = rayon::scope(|s| {
        let walker = WalkDir::new(&opt.path);
        for entry in walker {
            let entry = entry?;
            s.spawn(move |_| {
                // Check first if we should even still continue
                if INTERRUPTED.load(Ordering::Relaxed) {
                    return;
                }
                if is_log_file(&entry) {
                    let search = search_entry(&entry, filter);
                    match search {
                        Ok(None) => (),
                        Ok(Some(result)) => {
                            found_something.store(true, Ordering::Relaxed);
                            pipeline_ref.push_item(result);
                        }
                        Err(err) => {
                            debug!("Runtime error while scanning {:?}: {}", entry.path(), err);
                        }
                    }
                }
            });
        }
        Ok(())
    });
    result?;
    pipeline.finish();
    Ok(found_something.load(Ordering::Relaxed))
}

fn is_zip_file_name(file_name: &str) -> bool {
    file_name.ends_with(".zip") || file_name.ends_with(".zevtc")
}

/// Search the given directory entry.
///
/// This forwards to [`search_file`][search_file] with the right parameters, and as such has the
/// same return possibilities.
fn search_entry(entry: &DirEntry, filter: &dyn LogFilter) -> Result<Option<LogResult>> {
    let is_zip = entry
        .file_name()
        .to_str()
        .map(is_zip_file_name)
        .unwrap_or(false);
    search_file(entry.path(), is_zip, filter)
}

/// Search the given single log..
///
/// If the log matches, returns `Ok(Some(..))`.
/// If the log doesn't match, returns `Ok(None)`.
/// If there was a fatal error, returns `Err(..)`.
fn search_file(path: &Path, is_zip: bool, filter: &dyn LogFilter) -> Result<Option<LogResult>> {
    let file_stream = BufReader::new(File::open(path)?);
    let mut wrapper = if is_zip {
        ZipWrapper::zipped(file_stream)
    } else {
        ZipWrapper::raw(file_stream)
    };
    let mut stream = wrapper.get_stream();
    let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?;

    let early_log = EarlyLogResult {
        log_file: path.to_owned(),
        evtc: partial,
    };
    let early_ok = filter.filter_early(&early_log);
    let partial = early_log.evtc;

    if early_ok == Inclusion::Exclude {
        return Ok(None);
    }

    let raw = evtclib::raw::parser::finish_parsing(partial, &mut stream)?;
    let parsed = evtclib::process(&raw).ok();
    let log = if let Some(e) = parsed {
        e
    } else {
        debug!("log file cannot be parsed: {:?}", path);
        return Ok(None);
    };

    let info = extract_info(path, &log);

    let take_log = filter.filter(&info);

    if take_log {
        Ok(Some(info))
    } else {
        Ok(None)
    }
}

/// Extract human-readable information from the given log file.
fn extract_info(path: &Path, log: &Log) -> LogResult {
    let boss = log.encounter();
    if boss.is_none() {
        debug!(
            "log file has unknown boss: {:?} (id: {:#x})",
            path,
            log.encounter_id()
        );
    }

    let guild_ids = get_guild_mapping(log);

    let mut players = log
        .players()
        .map(|p| Player {
            account_name: p.account_name().to_owned(),
            character_name: p.character_name().to_owned(),
            profession: (p.profession(), p.elite()).into(),
            subgroup: p.subgroup(),
            guild_id: guild_ids.get(&p.addr()).cloned(),
        })
        .collect::<Vec<Player>>();
    players.sort();

    LogResult {
        log_file: path.to_path_buf(),
        time: Utc.timestamp(i64::from(log.local_end_timestamp().unwrap_or(0)), 0),
        duration: get_fight_duration(log),
        boss,
        players,
        outcome: get_fight_outcome(log),
        is_cm: log.is_cm(),
    }
}

/// Get a mapping of agent IDs to guild API strings.
fn get_guild_mapping(log: &Log) -> HashMap<u64, String> {
    log.events()
        .iter()
        .filter_map(|event| {
            if let EventKind::Guild {
                source_agent_addr,
                api_guild_id,
                ..
            } = event.kind()
            {
                api_guild_id
                    .as_ref()
                    .map(|api_id| (*source_agent_addr, api_id.clone()))
            } else {
                None
            }
        })
        .collect()
}

/// Get the outcome of the fight.
fn get_fight_outcome(log: &Log) -> FightOutcome {
    if log.was_rewarded() {
        FightOutcome::Success
    } else {
        FightOutcome::Wipe
    }
}

/// Get the duration of the fight.
fn get_fight_duration(log: &Log) -> Duration {
    let start = log.events().first().map(Event::time).unwrap_or(0) as i64;
    let end = log.events().last().map(Event::time).unwrap_or(0) as i64;
    Duration::milliseconds(end - start)
}