use std::collections::BTreeSet;
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::prelude::{Res, ResMut, Resource};
use retiretui_engine::market::{History, Progress, RunError, Runs, historical};
use retiretui_engine::optimize::{ClaimSearch, SweptBracket, optimize_claims, rank_key};
use retiretui_engine::params::TaxTables;
use retiretui_engine::plan::{Plan, TreatmentClass};
use retiretui_engine::project::Projection;
use super::rows::{Entry, Tone};
use crate::commands::tui::edit::Draft;
use crate::commands::tui::nav::{ActivePage, Page};
use crate::commands::tui::present::signed_money;
use crate::commands::tui::session::{Projected, Session};
use crate::commands::tui::tools::claims::HeldClaims;
use crate::commands::tui::tools::ladders::{self, rate_label};
use crate::commands::tui::tools::markets::MarketHistory;
use crate::commands::tui::tools::{Searches, Worker};
const NO_LADDER: &str = "no conversion ladder beats the plan";
const REFUSED: &str = "not searchable under the Roth Conversions answers";
const CLAIMS_AS_PLANNED: &str = "Claims as planned are best";
const NOTHING_TO_SEARCH: &str = "No conversion or claim to search";
type Searched = (Plan, BTreeSet<String>, toml::Table);
#[derive(Resource, Default)]
pub struct Better {
answered: Option<(Searched, Found)>,
running: Option<(Searched, Worker<Option<Found>>)>,
}
pub(crate) struct Found {
pub(super) historical: Option<Runs>,
claims: Option<ClaimSearch>,
ladders: Vec<Ladder>,
}
struct Ladder {
owner: String,
destination: String,
best: Option<SweptBracket>,
}
impl Better {
pub(crate) fn found(&self) -> Option<&Found> {
self.answered.as_ref().map(|(_, found)| found)
}
#[cfg(test)]
pub(crate) fn is_running(&self) -> bool {
self.running.is_some()
}
fn receive(&mut self) {
let Some((searched, worker)) = self.running.take() else {
return;
};
if let Some(Some(found)) = worker.join() {
self.answered = Some((searched, found));
}
}
fn queue(
&mut self,
(plan, held, answers): (&Plan, &BTreeSet<String>, toml::Table),
tables: &TaxTables,
history: &History,
) {
let is_wanted = |searched: &Searched| {
searched.0 == *plan && searched.1 == *held && searched.2 == answers
};
if self
.answered
.as_ref()
.is_some_and(|(searched, _)| is_wanted(searched))
{
self.running = None;
return;
}
if self
.running
.as_ref()
.is_some_and(|(searched, _)| is_wanted(searched))
{
return;
}
self.answered = None;
let wanted: Searched = (plan.clone(), held.clone(), answers);
let (tables, history) = (tables.clone(), history.clone());
let searched = wanted.clone();
let worker =
Worker::spawn(move |progress| search(&searched, (&tables, &history), progress));
self.running = Some((wanted, worker));
}
}
fn search(
(plan, held, answers): &Searched,
(tables, history): (&TaxTables, &History),
progress: &Progress,
) -> Option<Found> {
let historical = match historical(plan, tables, history, progress) {
Err(RunError::Cancelled) => return None,
ran => ran.ok(),
};
let held: Vec<String> = held.iter().cloned().collect();
let claims = unless_cancelled(progress, || optimize_claims(plan, tables, &[], &held).ok())?;
let mut ladders = Vec::new();
for owner in roth_owners(plan) {
let best = unless_cancelled(progress, || best_ladder(plan, tables, answers, owner))?;
ladders.push(best);
}
Some(Found {
historical,
claims,
ladders,
})
}
fn unless_cancelled<T>(progress: &Progress, work: impl FnOnce() -> T) -> Option<T> {
(!progress.is_cancelled()).then(work)
}
fn best_ladder(
plan: &Plan,
tables: &TaxTables,
answers: &toml::Table,
(owner, destination): (&str, &str),
) -> Ladder {
let best = ladders::options_into(plan, answers, destination)
.and_then(|(options, rate)| ladders::search(plan, tables, &options, rate).ok())
.and_then(|sweep| sweep.brackets.into_iter().next());
Ladder {
owner: owner.to_owned(),
destination: destination.to_owned(),
best,
}
}
fn roth_owners(plan: &Plan) -> impl Iterator<Item = (&str, &str)> {
plan.household.people.iter().filter_map(|person| {
let roth = plan.accounts.iter().find(|account| {
account.owner == person.id && account.treatment() == TreatmentClass::Roth
})?;
Some((person.id.as_str(), roth.id.as_str()))
})
}
pub(super) fn work(
(projected, active, searches): (Res<Projected>, Res<ActivePage>, Res<Searches>),
(session, history, held): (Res<Session>, Res<MarketHistory>, Res<HeldClaims>),
(draft, mut better): (Res<Draft>, ResMut<Better>),
) {
if better
.running
.as_ref()
.is_some_and(|(_, worker)| worker.is_finished())
{
better.receive();
}
let is_moved = projected.is_changed()
|| active.is_changed()
|| searches.is_changed()
|| held.is_changed()
|| draft.is_changed();
if !is_moved {
return;
}
if active.0 != Page::Overview || !searches.0 {
better.running = None;
return;
}
let wanted = (&projected.plan, &held.0, ladders::held_answers(&draft));
better.queue(wanted, &session.tables, &history.0);
}
pub(super) fn entries(better: &Better, projected: &Projected, nominal: bool) -> Vec<Entry> {
let Some(found) = better.found() else {
return vec![quiet(super::PENDING)];
};
let plan = &projected.plan;
let current = &projected.projection;
let mut rows: Vec<Entry> = found
.ladders
.iter()
.map(|ladder| {
let said = match &ladder.best {
None => REFUSED.to_owned(),
Some(best) if beats(&best.optimized, current) => {
let rate = rate_label(best.rate);
format!(
"convert to {rate}, {}",
gain(&best.optimized, current, nominal)
)
}
Some(_) => NO_LADDER.to_owned(),
};
let text = format!("{}: {said}", plan.person_name(&ladder.owner));
let aim = Some(ladder.destination.clone());
Entry::leading(text, (Page::RothConversions, aim))
})
.collect();
rows.extend(found.claims.as_ref().map(|search| {
let best = search.best();
let text = if beats(&best.projection, current) {
let claims: Vec<String> = (best.claims.iter())
.map(|claim| format!("{} at {}", plan.person_name(&claim.owner), claim.age))
.collect();
let gain = gain(&best.projection, current, nominal);
format!("Claim {}: {gain}", claims.join(", "))
} else {
CLAIMS_AS_PLANNED.to_owned()
};
Entry::leading(text, (Page::SsaBenefits, None))
}));
if rows.is_empty() {
rows.push(quiet(NOTHING_TO_SEARCH));
}
rows
}
fn quiet(text: &str) -> Entry {
Entry {
tone: Tone::Quiet,
..Entry::plain(text.to_owned())
}
}
fn beats(option: &Projection, current: &Projection) -> bool {
rank_key(option) < rank_key(current)
}
fn gain(option: &Projection, current: &Projection, nominal: bool) -> String {
let (own, base) = (option.summary(!nominal), current.summary(!nominal));
let ends = signed_money(own.final_net_worth - base.final_net_worth);
let unfunded = own.lifetime_unfunded - base.lifetime_unfunded;
if unfunded == 0 {
format!("ends {ends}")
} else {
format!("ends {ends}, unfunded {}", signed_money(unfunded))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::tui::support::{TEST_PLAN, projected_from};
#[test]
fn a_cancelled_search_stops_before_the_next_step() {
let refused = format!("{TEST_PLAN}\n[market.historical]\nfrom = 1800\n");
let searched = (
projected_from(&refused).plan,
BTreeSet::new(),
toml::Table::new(),
);
let tables = (&TaxTables::embedded(), History::embedded());
let progress = Progress::default();
let found = search(&searched, tables, &progress).expect("searched");
assert!(found.historical.is_none(), "the runs were refused");
progress.cancel();
assert!(search(&searched, tables, &progress).is_none());
}
}