use std::collections::{HashMap, HashSet};
use jiff::civil::Date;
use super::rules::{By, Outcome, Part, Rule};
use super::{Category, Patterns, Row};
use crate::plan::{Dollars, Plan, PlanDate};
use crate::project::inflation_factor;
use crate::tax::{MONTHS_PER_YEAR, month_index};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Of {
Category(Category),
Payee(String),
Containing(String),
Unnamed,
Ignored,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Window {
pub(super) start: i32,
pub(super) end: i32,
covered: f64,
}
pub(super) fn month_of(on: PlanDate) -> i32 {
month_index(on.year(), on.0.month())
}
impl Window {
pub(super) fn of(rows: &[Row]) -> Option<Self> {
let latest = rows.iter().map(|row| row.on.0).max()?;
let earliest = rows.iter().map(|row| row.on.0).min()?;
let first_month = (latest.first_of_month())
.checked_sub(jiff::Span::new().months(MONTHS_PER_YEAR - 1))
.unwrap_or(earliest);
let first = earliest.max(first_month);
let (start, end) = (month_of(PlanDate(first)), month_of(PlanDate(latest)));
let share = |days: i8, of: Date| f64::from(days) / f64::from(of.days_in_month());
let covered = share(first.days_in_month() - first.day() + 1, first)
+ f64::from(end - start - 1)
+ share(latest.day(), latest);
Some(Self {
start,
end,
covered,
})
}
pub(super) const fn months(self) -> i32 {
self.end - self.start + 1
}
pub(super) fn holds(self, row: &Row) -> bool {
(self.start..=self.end).contains(&month_of(row.on))
}
pub(super) fn annual(self, dollars: f64) -> Dollars {
(dollars * f64::from(MONTHS_PER_YEAR) / self.covered).round() as Dollars
}
}
struct Named<'a> {
name: String,
key: String,
embedded: Option<&'a Rule>,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Decided<'a> {
pub(super) outcome: Outcome,
by: &'a By,
}
impl<'a> Decided<'a> {
fn rule(rule: &'a Rule) -> Self {
Self {
outcome: rule.outcome,
by: &rule.by,
}
}
}
pub(super) struct Sorter<'a> {
patterns: &'a Patterns,
named: HashMap<&'a str, Named<'a>>,
}
impl<'a> Sorter<'a> {
pub(super) fn new(patterns: &'a Patterns) -> Self {
Self {
patterns,
named: HashMap::new(),
}
}
pub(super) fn sort(&mut self, row: &'a Row) -> (&str, Option<Decided<'a>>) {
let patterns = self.patterns;
let named = self.named.entry(&row.payee).or_insert_with(|| {
let name = patterns.name_of(&row.payee);
let key = name.to_ascii_uppercase();
let embedded = patterns.embedded_rule(&key, row);
Named {
name,
key,
embedded,
}
});
let decided = (patterns.user(&named.key, row).map(Decided::rule))
.or_else(|| {
let banks = row.category.as_deref().and_then(Category::from_text)?;
Some(Decided {
outcome: Outcome::Category(banks),
by: &By::Bank,
})
})
.or_else(|| {
Some(Decided {
outcome: Outcome::Category(row.kind?),
by: &By::Type,
})
})
.or_else(|| named.embedded.map(Decided::rule));
(&named.name, decided)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sorted<'a> {
pub row: &'a Row,
pub name: String,
pub decided: Option<(Outcome, By)>,
}
#[must_use]
pub fn sorted_records<'a>(rows: &'a [Row], patterns: &Patterns, of: &Of) -> Vec<Sorted<'a>> {
let Some(window) = Window::of(rows) else {
return Vec::new();
};
let wanted = match of {
Of::Containing(words) => Part::new(words),
_ => Part::new(""),
};
let mut sorter = Sorter::new(patterns);
let mut found: Vec<Sorted> = Vec::new();
for row in rows.iter().filter(|row| window.holds(row)) {
let (name, decided) = sorter.sort(row);
let outcome = decided.map(|decided| decided.outcome);
let is_unnamed = matches!(outcome, None | Some(Outcome::Bucket));
let is_of = match of {
Of::Category(wanted) => outcome == Some(Outcome::Category(*wanted)),
Of::Payee(wanted) => is_unnamed && name == wanted,
Of::Containing(_) => [name, &row.payee]
.iter()
.any(|text| wanted.holds(&text.to_ascii_uppercase())),
Of::Unnamed => is_unnamed,
Of::Ignored => outcome == Some(Outcome::Ignore),
};
if is_of {
found.push(Sorted {
row,
name: name.to_owned(),
decided: decided.map(|decided| (decided.outcome, decided.by.clone())),
});
}
}
found.sort_by_key(|sorted| std::cmp::Reverse(sorted.row.on));
found
}
#[must_use]
pub fn spent_a_year(plan: &Plan, rows: &[Row], found: &[Sorted]) -> Dollars {
let Some(window) = Window::of(rows) else {
return 0;
};
let spent: f64 = (found.iter())
.map(|sorted| sorted.row.spent().dollars() / inflation_factor(plan, sorted.row.on.year()))
.sum();
window.annual(spent)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Reach {
pub payees: usize,
pub spent: Dollars,
}
#[must_use]
pub fn reach(plan: &Plan, rows: &[Row], patterns: &Patterns, part: &str) -> Reach {
let part = Part::new(part);
let Some(window) = Window::of(rows).filter(|_| !part.is_empty()) else {
return Reach::default();
};
let mut names: HashMap<&str, String> = HashMap::new();
let mut payees = HashSet::new();
let mut spent = 0.0;
for row in rows.iter().filter(|row| window.holds(row)) {
let name = names
.entry(&row.payee)
.or_insert_with(|| patterns.name_of(&row.payee).to_ascii_uppercase());
if part.holds(name) {
payees.insert(name.clone());
spent += row.spent().dollars() / inflation_factor(plan, row.on.year());
}
}
Reach {
payees: payees.len(),
spent: window.annual(spent),
}
}