/*
 * Copyright 2023 Alexander Garver
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

//! # File Processor
//!
//! This module contains the `FileProcessor` struct. Its core functionality is to build a
//! "configuration", take one filepath (at a time), and process that file to derive statistics in a
//! consistent way.

use std::{collections::HashMap, fs::read_to_string, io::Error as IoError, path::Path};

use crate::args::prelude::*;
use crate::statistics::prelude::*;

// Allowed for prelude specificlally
#[allow(clippy::missing_docs_in_private_items)]
pub mod prelude {
	pub use super::FileProcessor;
}

/// # File Processor
///
/// This struct's core functionality is to enable repeatable analysis by building a
/// "configuration", taking one filepath (at a time), and processing that file to derive
/// statistics. The filepath can be read only; no modification is done to the file itself.
///
/// ## Process
///
/// The processing done on files happens in a couple specific steps:
/// 1. Character and Word processing
/// 2. Word analysis
/// 3. Semantic processing
/// 4. File analysis
///
/// ### Character and Word Processing
///
/// In the first step, the text is filtered to take out things that aren't typically part of words.
/// Generally, that includes most punctuation, formatting characters, and in the future it should
/// include whole words (e.g. to filter out articles or pronouns).
///
/// Filtered text can be removed ("#hashtag" becomes "hashtag") or replaced with whitespace
/// ("dash-separator" becomes "dash separator").
///
/// ### Word Analysis
///
/// Words are counted in this step. No further analysis is done at this point.
///
/// ### Semantic Processing
///
/// This step isn't currently implemented.
///
/// What technically happens in this step is word substitutions. It's intended for to flatten
/// semantic nuance, e.g. to group plural and single forms of a word, or to categorize various
/// types of vehicles as "car" if the distinction between categories is irrelevant.
///
/// ### File Analysis
///
/// This step isn't currently implemented.
///
/// After semantic flattening, word (or topic) frequency can be measured relative to other words in
/// the text. Some amount of meta analysis may be performed, such as normalizing word frequency
/// agaisnt a reference body of text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileProcessor {
	/// # Filter Characters
	///
	/// This string is parsed as an array characters to filter out of the text. Every individual
	/// character in the string is removed from the text being processed.
	///
	/// Has no effect of `no_filter` is `false`.
	filter_chars: String,
	/// # Delimiter Characters
	///
	/// Similar to the filter characters, this string is parsed as an array of cahracters. However,
	/// instead of removing the character entirely, it's replaced with whitespace. This splits words
	/// before they're counted.
	delimit_chars: String,
	/// # Case-Sensitive Count
	///
	/// If true, words are counted case-sensitively. If false, `.to_lowercase()` is called before
	/// words are counted.
	case_sensitive: bool,
}

impl FileProcessor {
	#[must_use]
	pub const fn new(filter_chars: String, delimit_chars: String, case_sensitive: bool) -> Self {
		Self {
			filter_chars,
			delimit_chars,
			case_sensitive,
		}
	}

	/// # Count Words in File
	///
	/// Given a filepath, this function reads its contents into a `String`, filters out formatting
	/// characters (based on the fields of `self`), then counts the words left.
	///
	/// ## Errors
	///
	/// Transparently raises any IO error.
	///
	/// ## Allows
	///
	/// One allow for `clippy::if_not_else`. The conditional being checked reads better as "If the
	/// string is not empty..." and to achieve that, we call `!is_empty()`.
	pub fn count_words_in_file<P>(&self, file: P) -> Result<HashMap<String, usize>, IoError>
	where
		P: AsRef<Path>,
	{
		let mut file_contents = read_to_string(file)?;

		if !self.delimit_chars.is_empty() {
			#[allow(clippy::if_not_else)]
			if !self.filter_chars.is_empty() {
				file_contents = self.delimit_filter_str(&file_contents);
			} else {
				file_contents = self.delimit_str(&file_contents);
			}
		} else if !self.filter_chars.is_empty() {
			file_contents = self.filter_str(&file_contents);
		}

		if !self.case_sensitive {
			file_contents = file_contents.to_lowercase();
		}

		let word_counts = Self::count_words_in_str(&file_contents);
		Ok(word_counts)
	}

	///#  Populate `FileStatistics`
	///
	/// Reads the file at `stats.name` and does the configured string processing, then populates the
	/// `FileStatistics` with the word counts and sorts it.
	///
	/// # Errors
	///
	/// This function transparently returns any `std::io::Error` which may arise, specifically when
	/// trying to read the file into a string.
	pub fn populate_file_statistics(
		&self,
		stats: &mut FileStatistics<Unsorted>,
	) -> Result<(), IoError> {
		let mut file_contents = read_to_string(&stats.name)?;

		if !self.delimit_chars.is_empty() {
			#[allow(clippy::if_not_else)]
			if !self.filter_chars.is_empty() {
				file_contents = self.delimit_filter_str(&file_contents);
			} else {
				file_contents = self.delimit_str(&file_contents);
			}
		} else if !self.filter_chars.is_empty() {
			file_contents = self.filter_str(&file_contents);
		}

		if !self.case_sensitive {
			file_contents = file_contents.to_lowercase();
		}

		for word in file_contents.split_whitespace() {
			stats.count_word(word);
		}
		Ok(())
	}

	/// # Filter String Slice
	///
	/// Given a string slice, this function creates returns an owned string but without any of the
	/// characters found in `self.filter_chars`. Typically, this is used to filter out formatting
	/// characters from a file before counting words.
	fn filter_str(&self, str: &str) -> String {
		let mut filtered_string: String = String::new();
		for c in str.chars() {
			if !self.filter_chars.contains(c) {
				filtered_string.push(c);
			}
		}
		filtered_string
	}

	/// # Delimit String Slice
	///
	/// Delimits the string slice based on `self.delimit_chars`. Each character in that string is
	/// either pushed to a `String` if it doesn't match a delimit character, or replaced with a
	/// space if it does.
	fn delimit_str(&self, str: &str) -> String {
		let mut delimited_string: String = String::new();
		for c in str.chars() {
			if self.delimit_chars.contains(c) {
				delimited_string.push(' ');
			} else {
				delimited_string.push(c);
			}
		}
		delimited_string
	}

	/// # Delimit and Filter String Slice
	///
	/// This is an optimization function that exists so delimiting and filtering can be done in one
	/// iteration over the text being prcessed rather than requiring different passes.
	fn delimit_filter_str(&self, str: &str) -> String {
		let mut result = String::new();

		for c in str.chars() {
			if self.delimit_chars.contains(c) {
				result.push(' ');
			} else if !self.filter_chars.contains(c) {
				result.push(c);
			}
		}

		result
	}

	/// # Count Words in String Slice
	///
	/// Creates an iterator over whitespace-separated words in the input string, and keeps a running
	/// tally of them.
	fn count_words_in_str(str: &str) -> HashMap<String, usize> {
		let mut word_counts: HashMap<String, usize> = HashMap::new();
		for word in str.split_whitespace() {
			let word_count: &mut usize = word_counts.entry(word.to_owned()).or_insert(0);
			*word_count += 1;
		}
		word_counts
	}
}

impl Default for FileProcessor {
	fn default() -> Self {
		Self {
			filter_chars: String::from(".,!?;:#*_[](){}<>\\|/‘’“”\"'"),
			delimit_chars: String::default(),
			case_sensitive: false,
		}
	}
}

impl From<&Args> for FileProcessor {
	fn from(args: &Args) -> Self {
		Self {
			filter_chars: args.filter_chars.clone(),
			delimit_chars: args.delimit_chars.clone(),
			case_sensitive: args.case_sensitive,
		}
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use std::{env, fs, io::ErrorKind::AlreadyExists as FileExists, path::PathBuf};

	use rand::random;

	#[test]
	fn file_processor_new() {
		let fp = FileProcessor::new("".to_string(), "".to_string(), false);
		assert_eq!(fp.filter_chars, "".to_string());
		assert_eq!(fp.delimit_chars, "".to_string());
	}

	#[test]
	fn file_processor_from_args() {
		let args = Args {
			paths: vec!["test.txt".to_string()],
			filter_chars: "?!".to_string(),
			delimit_chars: "-_".to_string(),
			case_sensitive: false,
		};

		let fp = FileProcessor::from(&args);
		assert_eq!(fp.filter_chars, "?!".to_string());
		assert_eq!(fp.delimit_chars, "-_".to_string());
	}

	#[test]
	fn defaults_equivalent() {
		let mock_stdin = [""];
		let args = Args::parse_from(mock_stdin.iter());
		let from_args = FileProcessor::from(&args);

		let default = FileProcessor::default();

		assert_eq!(from_args, default);
	}

	#[test]
	fn filter_chars_from_string() {
		let fp = FileProcessor::new("?!".to_string(), "".to_string(), false);

		let filtered = fp.filter_str("Hello!!!?? World");
		assert_eq!(filtered, "Hello World");
	}

	#[test]
	fn delimiter() {
		let fp = FileProcessor::new("".to_string(), "l".to_string(), false);

		let delimited = fp.delimit_str("Hello world");
		assert_eq!(delimited, "He  o wor d");
	}

	#[test]
	fn delimit_and_filter() {
		let fp = FileProcessor::new("?!".to_string(), "l".to_string(), false);

		let delimited = fp.delimit_str("Hello!!!?? World");
		let filtered = fp.filter_str(&delimited);
		assert_eq!(filtered, "He  o Wor d");
	}

	#[test]
	fn delimit_filter() {
		let fp = FileProcessor::new("?!".to_string(), "l".to_string(), false);

		let result = fp.delimit_filter_str("Hello!!!?? World");
		assert_eq!(result, "He  o Wor d");
	}

	#[test]
	fn single_pass_same_as_separate() {
		let fp = FileProcessor::new("?!".to_string(), "l".to_string(), false);

		let single_pass = fp.delimit_filter_str("Hello!!!?? World");

		let double_pass = fp.delimit_str("Hello!!!?? World");
		let double_pass = fp.filter_str(&double_pass);

		assert_eq!(single_pass, double_pass);
	}

	#[test]
	fn count_words_in_str() {
		let word_counts = FileProcessor::count_words_in_str("hello world hello");

		assert_eq!(word_counts.get("hello"), Some(&2));
		assert_eq!(word_counts.get("world"), Some(&1));
	}

	#[test]
	fn count_words_on_different_lines() {
		let word_counts =
			FileProcessor::count_words_in_str("hello\nworld\n\n\n\n\n\nhello\n \n hello");

		assert_eq!(word_counts.get("hello"), Some(&3));
		assert_eq!(word_counts.get("world"), Some(&1));
	}

	/// # Generate Filepath
	///
	/// Utility function to generate a random filepath within a given directory.
	fn gen_filepath(dir: &PathBuf) -> PathBuf {
		let mut filepath = dir.clone();
		filepath.push(format!("text_{}.txt", random::<usize>()));
		return filepath;
	}

	/// # Setup File
	///
	/// Utility function to set up tests with a text file.
	fn setup_file() -> PathBuf {
		let contents: &str = "But Mousie, thou art no thy-lane,\n\
									In proving foresight may be vain:\n\
									The best laid schemes o' Mice an' Men\n\
									Gang aft agley,\n\
									An' lea'e us nought but grief an' pain,\n\
									For promis'd joy!";

		let mut pwd = env::temp_dir();
		pwd.push("tagalyzer_tests");
		if let Err(e) = fs::DirBuilder::new().create(&pwd) {
			if e.kind() != FileExists {
				panic!()
			}
		};

		let filepath = gen_filepath(&pwd);

		fs::write(&filepath, &contents).unwrap();
		filepath
	}

	#[test]
	fn file_does_not_exist() {
		let mut pwd = env::temp_dir();
		pwd.push("tagalyzer_tests");

		let filepath = gen_filepath(&pwd);

		let fp = FileProcessor::new("".to_string(), "".to_string(), false);
		let res = fp.count_words_in_file(filepath);
		assert!(res.is_err());
	}

	#[test]
	fn count_words_in_file() {
		let filepath = setup_file();

		let fp = FileProcessor::new("',".to_string(), "".to_string(), false);
		let counts = fp.count_words_in_file(filepath).unwrap();

		assert_eq!(counts.get("mousie"), Some(&1));
		assert_eq!(counts.get("schemes"), Some(&1));
		assert_eq!(counts.get("an"), Some(&3));
		assert_eq!(counts.keys().len(), 31);
	}

	#[test]
	fn count_words_case_sensitive() {
		let filepath = setup_file();

		let fp = FileProcessor::new("',".to_string(), "".to_string(), true);
		let counts = fp.count_words_in_file(filepath).unwrap();

		assert_eq!(counts.get("Mousie"), Some(&1));
		assert_eq!(counts.get("schemes"), Some(&1));
		assert_eq!(counts.get("an"), Some(&2));
		assert_eq!(counts.keys().len(), 33);
	}

	#[test]
	fn delimit_from_file() {
		let filepath = setup_file();

		let fp = FileProcessor::new("',".to_string(), "-".to_string(), false);
		let counts = fp.count_words_in_file(filepath).unwrap();

		assert_eq!(counts.get("thy"), Some(&1));
		assert_eq!(counts.get("lane"), Some(&1));
		assert_eq!(counts.keys().len(), 32);
	}

	#[test]
	fn delimit_before_filter() {
		let filepath = setup_file();

		let fp = FileProcessor::new("-,".to_string(), "-".to_string(), false);
		let counts = fp.count_words_in_file(filepath).unwrap();

		assert_eq!(counts.get("thy"), Some(&1));
		assert_eq!(counts.get("lane"), Some(&1));
		assert_eq!(counts.keys().len(), 32);
	}

	#[test]
	fn smoke_test() {
		let filepath = setup_file();

		let args = Args {
			paths: vec![filepath.to_string_lossy().into_owned()],
			filter_chars: "!',:".to_string(),
			delimit_chars: "".to_string(),
			case_sensitive: false,
		};
		let fp = FileProcessor::from(&args);
		let counts = fp.count_words_in_file(&args.paths[0]).unwrap();

		assert_eq!(counts.get("mousie"), Some(&1));
		assert_eq!(counts.get("schemes"), Some(&1));
		assert_eq!(counts.get("an"), Some(&3));
		assert_eq!(counts.keys().len(), 31);
	}
}
