//! Modified Dietz return calculator (Issue #142 / Bilan #4).
//!
//! Computes the time- and contribution-weighted return of a single account
//! over a period, given:
//!   - the account value at `period_start` (snapshot lookup, may be missing),
//!   - the account value at `period_end`   (snapshot lookup, may be missing),
//!   - the cash flows during the period (linked transfers — `+` for IN,
//!     `-` for OUT; the caller already applies the direction sign).
//!
//! Modified Dietz formula:
//!
//! ```text
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
//! ```
//!
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
//! to flow date`. A flow on day 0 is fully invested for the whole period
//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0).
//!
//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive
//! length. A zero-length period (`period_start == period_end`) skips the
//! annualization step (would divide by zero).
//!
//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can
//! render an explicit warning instead of an opaque empty value):
//!   - `value_start == None`            → `is_partial = true`, `return_pct = None`
//!   - `value_end == None`              → `is_partial = true`, `return_pct = None`
//!   - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
//!     return collapses to the simple `(V_end - V_start) / V_start`
//!   - `period_start == period_end` → no annualization (stays = return_pct)
//!   - V_start = 0 and first flow > 0 → account created mid-period; the
//!     denominator is `0 + W_first * CF_first`, which is positive as long as
//!     the flow lands strictly before period_end
//!   - account depleted then refilled → mathematically defined; the function
//!     does not panic but the magnitude can look extreme — that is the
//!     inherent Modified Dietz behaviour on accounts with near-zero invested
//!     capital.
//!
//! Module is **private to the crate** (`pub(crate)`) and lives under
//! `commands/` per the spec — reused only by `balance_commands.rs`.

use chrono::NaiveDate;
use serde::Serialize;

/// Result of a Modified Dietz computation, ready to ship across the Tauri
/// boundary. Optional fields are `None` whenever the calculation cannot be
/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip
/// pointing at `is_partial` / `has_no_transfers_warning`.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AccountReturn {
    /// Account value at `period_start` (latest snapshot ≤ period_start).
    pub value_start: Option<f64>,
    /// Account value at `period_end` (latest snapshot ≤ period_end).
    pub value_end: Option<f64>,
    /// Sum of signed cash flows during the period (`+` IN, `-` OUT).
    pub net_contributions: f64,
    /// Modified Dietz return as a fraction (0.05 = +5%). `None` if either
    /// endpoint snapshot is missing or the denominator is non-positive.
    pub return_pct: Option<f64>,
    /// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
    /// periods or whenever `return_pct` is `None`.
    pub annualized_pct: Option<f64>,
    /// `true` when at least one snapshot endpoint is missing — the UI labels
    /// the result as "partial / non-significatif".
    pub is_partial: bool,
    /// `true` when the account had zero linked transfers during the period —
    /// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`,
    /// but the UI surfaces a warning so the user can verify whether real
    /// transfers were forgotten (untagged contributions skew the return).
    pub has_no_transfers_warning: bool,
}

impl AccountReturn {
    /// Default partial return when an endpoint is missing — keeps the
    /// constructor calls in the algorithm body terse.
    fn partial(
        value_start: Option<f64>,
        value_end: Option<f64>,
        net_contributions: f64,
        has_no_transfers_warning: bool,
    ) -> Self {
        Self {
            value_start,
            value_end,
            net_contributions,
            return_pct: None,
            annualized_pct: None,
            is_partial: true,
            has_no_transfers_warning,
        }
    }
}

/// Computes the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. See module docs for the full formula and
/// edge-case handling.
///
/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for
/// applying the direction sign (`in` → `+`, `out` → `−`) and for filtering
/// flows to the period; flows outside `[period_start, period_end]` are
/// skipped here too as a safety net.
pub(crate) fn modified_dietz(
    value_start: Option<f64>,
    value_end: Option<f64>,
    cash_flows: &[(NaiveDate, f64)],
    period_start: NaiveDate,
    period_end: NaiveDate,
) -> AccountReturn {
    // Filter flows to the period (defensive — caller already does this via
    // SQL, but keep the guarantee here so the math never sees out-of-range
    // weights).
    let in_period: Vec<(NaiveDate, f64)> = cash_flows
        .iter()
        .copied()
        .filter(|(d, _)| *d >= period_start && *d <= period_end)
        .collect();

    let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum();
    let has_no_transfers_warning = in_period.is_empty();

    // Endpoint guards — without both V_start and V_end we cannot return a
    // numeric result.
    let v_start = match value_start {
        Some(v) => v,
        None => {
            return AccountReturn::partial(
                value_start,
                value_end,
                net_contributions,
                has_no_transfers_warning,
            );
        }
    };
    let v_end = match value_end {
        Some(v) => v,
        None => {
            return AccountReturn::partial(
                value_start,
                value_end,
                net_contributions,
                has_no_transfers_warning,
            );
        }
    };

    // Period length in days. `(period_end - period_start)` returns
    // `chrono::Duration`; `.num_days()` is `i64`. A zero-length period
    // (same-day) skips weighting and annualization.
    let total_days = (period_end - period_start).num_days();

    let denominator: f64 = if total_days <= 0 {
        // Same-day period: weights collapse to either 0 or undefined; treat
        // every flow as fully invested (W = 1) so the denominator is
        // V_start + sum(CF). This keeps the function defined when callers
        // pass `period_start == period_end`.
        v_start + net_contributions
    } else {
        let total = total_days as f64;
        let weighted_sum: f64 = in_period
            .iter()
            .map(|(date, cf)| {
                let t_i = (*date - period_start).num_days() as f64;
                let w_i = (total - t_i) / total;
                w_i * cf
            })
            .sum();
        v_start + weighted_sum
    };

    // A non-positive denominator means we have no invested base to annualize
    // against (e.g. depleted then refilled with a single late flow). Return
    // the raw V_end - V_start - CF as the numerator and flag is_partial so
    // the UI can show "Performance non significative" — but only when V_start
    // is also 0 / negative; if V_start > 0 we keep the standard math.
    if denominator <= 0.0 {
        return AccountReturn {
            value_start: Some(v_start),
            value_end: Some(v_end),
            net_contributions,
            return_pct: None,
            annualized_pct: None,
            is_partial: true,
            has_no_transfers_warning,
        };
    }

    let numerator = v_end - v_start - net_contributions;
    let return_pct = numerator / denominator;

    // Annualization only makes sense for strictly positive periods.
    let annualized_pct = if total_days > 0 {
        let exponent = 365.0 / total_days as f64;
        Some((1.0 + return_pct).powf(exponent) - 1.0)
    } else {
        None
    };

    AccountReturn {
        value_start: Some(v_start),
        value_end: Some(v_end),
        net_contributions,
        return_pct: Some(return_pct),
        annualized_pct,
        is_partial: false,
        has_no_transfers_warning,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Small helper that turns a `YYYY-MM-DD` string literal into a
    /// `NaiveDate` — keeps the test bodies readable.
    fn d(s: &str) -> NaiveDate {
        NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses")
    }

    fn approx(a: f64, b: f64, tol: f64) -> bool {
        (a - b).abs() <= tol
    }

    #[test]
    fn nominal_two_flows_at_one_quarter_and_three_quarters() {
        // 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end =
        // 1100. CF1 = +50 at day 25, CF2 = +30 at day 75.
        let start = d("2026-01-01");
        let end = d("2026-04-11"); // 100 days later
        let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)];

        let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);

        // Sanity / shape
        assert_eq!(r.value_start, Some(1000.0));
        assert_eq!(r.value_end, Some(1100.0));
        assert_eq!(r.net_contributions, 80.0);
        assert!(!r.is_partial);
        assert!(!r.has_no_transfers_warning);

        // Hand calc:
        //   T = 100, t1 = 25, t2 = 75
        //   W1 = 75/100 = 0.75, W2 = 25/100 = 0.25
        //   numerator   = 1100 - 1000 - 80                = 20
        //   denominator = 1000 + 0.75*50 + 0.25*30        = 1045
        //   R           = 20 / 1045                       ≈ 0.01913876
        let r_pct = r.return_pct.expect("nominal case has a return");
        assert!(
            approx(r_pct, 20.0 / 1045.0, 1e-9),
            "return_pct = {} (expected ≈ {})",
            r_pct,
            20.0 / 1045.0
        );

        // Annualization: (1 + R)^(365/100) - 1
        let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0;
        let ann = r.annualized_pct.expect("nominal case is annualized");
        assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann);
    }

    #[test]
    fn no_prior_snapshot_marks_partial() {
        let start = d("2026-01-01");
        let end = d("2026-04-01");
        let flows = vec![(d("2026-02-01"), 200.0)];

        let r = modified_dietz(None, Some(1500.0), &flows, start, end);

        assert_eq!(r.value_start, None);
        assert_eq!(r.value_end, Some(1500.0));
        assert!(r.is_partial, "missing V_start must flag is_partial");
        assert_eq!(r.return_pct, None);
        assert_eq!(r.annualized_pct, None);
        assert!(!r.has_no_transfers_warning);
        // Still surface the contributions sum for the UI breakdown card.
        assert_eq!(r.net_contributions, 200.0);
    }

    #[test]
    fn no_end_snapshot_marks_partial() {
        let start = d("2026-01-01");
        let end = d("2026-04-01");
        let flows = vec![(d("2026-02-15"), -100.0)];

        let r = modified_dietz(Some(2000.0), None, &flows, start, end);

        assert_eq!(r.value_start, Some(2000.0));
        assert_eq!(r.value_end, None);
        assert!(r.is_partial);
        assert_eq!(r.return_pct, None);
        assert_eq!(r.annualized_pct, None);
        assert_eq!(r.net_contributions, -100.0);
    }

    #[test]
    fn account_created_mid_period_with_first_flow() {
        // V_start = 0, single +500 flow at day 30 of a 90-day period, V_end
        // = 510. The flow's weight is W = (90-30)/90 = 2/3.
        let start = d("2026-01-01");
        let end = d("2026-04-01"); // 90 days
        let flows = vec![(d("2026-01-31"), 500.0)];

        let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end);

        // numerator = 510 - 0 - 500 = 10
        // W = (90-30)/90 ≈ 0.6666667
        // denominator = 0 + 0.6666667 * 500 ≈ 333.3333
        // R ≈ 10 / 333.3333 = 0.03
        let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0);
        let r_pct = r.return_pct.expect("account-created case computes");
        assert!(
            approx(r_pct, expected, 1e-9),
            "return_pct = {} (expected ≈ {})",
            r_pct,
            expected
        );
        assert!(!r.is_partial);
        assert!(!r.has_no_transfers_warning);
    }

    #[test]
    fn depleted_then_refilled_does_not_panic() {
        // Pathological: V_start = 100, then -100 flow on day 1 (account
        // emptied), then +200 flow on day 60 of a 90-day period, V_end =
        // 210. Modified Dietz handles this without panicking; the value
        // may look extreme but the function must stay defined.
        let start = d("2026-01-01");
        let end = d("2026-04-01");
        let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)];

        let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end);

        // Whatever the math says, the call must complete cleanly. We don't
        // assert a precise return — the goal is "no panic, finite output if
        // the denominator is positive, else partial flag".
        if let Some(rp) = r.return_pct {
            assert!(rp.is_finite(), "return must be a finite f64");
        }
        // Net flows = -100 + 200 = 100
        assert_eq!(r.net_contributions, 100.0);
        // Not flagged "no transfers" since we have two flows.
        assert!(!r.has_no_transfers_warning);
    }

    #[test]
    fn no_transfers_collapses_to_simple_return() {
        // No cash flows → R should equal (V_end - V_start) / V_start exactly.
        let start = d("2026-01-01");
        let end = d("2026-04-01");
        let flows: Vec<(NaiveDate, f64)> = vec![];

        let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);

        assert!(r.has_no_transfers_warning);
        assert_eq!(r.net_contributions, 0.0);
        let r_pct = r.return_pct.expect("simple-return case has a value");
        let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1
        assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct);
    }

    #[test]
    fn annualization_on_90_day_period_matches_compound_formula() {
        // Direct check of the annualization branch with a clean R.
        let start = d("2026-01-01");
        let end = d("2026-04-01"); // 90 days
        let flows: Vec<(NaiveDate, f64)> = vec![];

        // V_start = 1000, V_end = 1050 → R = 0.05
        let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end);
        let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0;
        let ann = r.annualized_pct.expect("90-day period annualizes");
        assert!(
            approx(ann, expected_ann, 1e-12),
            "annualized = {} (expected {})",
            ann,
            expected_ann
        );
    }
}
