mod income_schedules;

use income_schedules::{all_paths, Schedule};
use rand::rngs::SmallRng;
use rand::{Rng, SeedableRng as _};
use rayon::prelude::*;
use std::collections::HashMap;

const LOAN_RATE_BASE: f64    = 0.059;
const SUPER_RATE: f64        = 0.12;
// Super return: 30-yr balanced fund average ~7.5% (AustralianSuper/industry data);
// std dev ~11.5% from observed annual range (-12% GFC to +20% boom years)
const SUPER_RETURN_MEAN: f64 = 0.075;
const SUPER_RETURN_STD: f64  = 0.115;
const SUPER_TAX: f64         = 0.15;
const COST_INFLATION: f64    = 0.03;
const RATE_NEUTRAL: f64      = 0.055;
const RATE_FLOOR: f64        = 0.018;
const RATE_CEILING: f64      = 0.11;
// HECS indexed annually at lower of CPI or WPI (Education Amendment Act 2024);
// 2025 rate was 3.2%; long-run proxy = COST_INFLATION = 3%
const HECS_INDEXATION: f64   = 0.030;
// JobSeeker single, no children, March 2026: $808.70/fn × 26 = $21,026/yr
const JOBSEEKER_ANNUAL: f64  = 21_026.0;
// Geraldton regional build cost: Perth standard ~$2,500/sqm + ~20% regional premium
const BUILD_RATE_NOW: f64    = 3000.0;
const BUILD_INFLATION: f64   = 0.03;
const RAAF_SUPER_RATE: f64   = 0.164; // ADF employer super (16.4% vs civilian 12%)
const N_RUNS: usize          = 200;
const SAVINGS_RETURN: f64    = 0.04;  // annual return on liquid savings (HISA / conservative invested cash)
// Ongoing property ownership costs: council rates ~$2k, water ~$1.5k, building insurance ~$3k,
// maintenance reserve ~0.5% of value — blended to 1.2% of current property value per year.
// Source: WA rates notices, RACWA insurance benchmarks, HIA maintenance guidelines.
const PROP_HOLDING_RATE: f64 = 0.012;
// ATO 2024-25 concessional super contributions cap (employer SG + salary sacrifice combined)
const SUPER_CONC_CAP: f64    = 30_000.0;
// Property acquisition costs beyond stamp duty and deposit:
// conveyancing ~$2k, building inspection ~$800, pest inspection ~$400, lender establishment ~$800,
// moving + utility connections ~$2.5k. Average across Geraldton / Perth / Canberra markets.
const ACQUISITION_COSTS: f64 = 6_500.0;
// Debt rate applied when cum_liq < 0. Property owners in deficit typically use a line of credit
// secured against their equity (~8-9%), not a credit card (20%). 9% is conservative for that.
const DEBT_RATE: f64 = 0.09;
// Super fund admin + insurance fees above the net-of-investment-fee return.
// AustralianSuper/industry: admin ~0.10% + inside-fund life+TPD+IP insurance ~$900-1,500/yr.
// Modelled as 0.30% of balance; applied to the whole balance after gross investment return.
const SUPER_FEE_RATE: f64 = 0.003;

fn calc_tax(g: f64) -> f64 {
    let t = if g <= 18200.0 {
        0.0
    } else if g <= 45000.0 {
        (g - 18200.0) * 0.16
    } else if g <= 135000.0 {
        (45000.0 - 18200.0) * 0.16 + (g - 45000.0) * 0.30
    } else if g <= 190000.0 {
        (45000.0 - 18200.0) * 0.16 + 90000.0 * 0.30 + (g - 135000.0) * 0.37
    } else {
        (45000.0 - 18200.0) * 0.16 + 90000.0 * 0.30 + 55000.0 * 0.37 + (g - 190000.0) * 0.45
    };
    // Medicare levy 2025-26: exempt below $27,222, phase-in $27,222-$34,028, full 2% above
    let m = if g <= 27222.0 {
        0.0
    } else if g <= 34028.0 {
        (g - 27222.0) * 0.10
    } else {
        g * 0.02
    };
    let lito = if g <= 37500.0 {
        700.0
    } else if g <= 45000.0 {
        700.0 - (g - 37500.0) * 0.05
    } else if g <= 66667.0 {
        325.0 - (g - 45000.0) * 0.015
    } else {
        0.0
    };
    f64::max(0.0, t + m - lito)
}

fn calc_hecs(g: f64, debt: f64) -> f64 {
    // New marginal HELP repayment system from 2025-26 (replaces flat-rate cliff system)
    // Source: ATO + Education Amendment Act 2024
    if debt <= 0.0 || g < 67000.0 { return 0.0; }
    let repayment = if g <= 125000.0 {
        (g - 67000.0) * 0.15                          // 15c per $1 over $67k
    } else if g <= 179285.0 {
        8700.0 + (g - 125000.0) * 0.17               // + 17c per $1 over $125k
    } else {
        g * 0.10                                       // 10% of total income above $179,285
    };
    f64::min(repayment, debt)
}

// LMI is required when deposit < 20%. Capitalised onto the loan — no upfront cash needed.
// Approximate QBE/Genworth schedule: ~2.5% of loan at 90% LVR (10% deposit).
fn calc_lmi(loan_amt: f64, deposit_pct: f64) -> f64 {
    if deposit_pct >= 0.20 { return 0.0; }
    loan_amt * 0.025
}

// Box-Muller transform: two uniform samples → one standard-normal sample
// Used to draw annual super returns from N(SUPER_RETURN_MEAN, SUPER_RETURN_STD²)
fn sample_super_return(rng: &mut SmallRng) -> f64 {
    let u1 = rng.gen_range(1e-10_f64..1.0_f64); // exclude 0 to avoid ln(0)
    let u2 = rng.r#gen::<f64>();
    let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
    // Clamp: floor at -25% (below worst observed year), ceiling at +40% (above best observed)
    (SUPER_RETURN_MEAN + SUPER_RETURN_STD * z).clamp(-0.25, 0.40)
}

// Perth/Canberra rents track property appreciation; regional rents track CPI
fn rent_inflation_rate(loc: &str) -> f64 {
    match loc {
        "PERTH" | "NONE" => 0.045, // Perth rents ~4.5%/yr long-run; unit median $560/wk Jan 2026
        "CANBERRA"        => 0.035,
        _                 => COST_INFLATION, // Geraldton/regional: ~3%/yr
    }
}

fn loan_pmt_annual(principal: f64, rate: f64, years: u32) -> f64 {
    let r = rate / 12.0;
    let n = (years * 12) as i32;
    let factor = (1.0 + r).powi(n);
    principal * r * factor / (factor - 1.0) * 12.0
}

fn generate_rate_path(rng: &mut SmallRng) -> [f64; 33] {
    let mut rate = LOAN_RATE_BASE;
    let mut path = [0.0f64; 33];
    for slot in path.iter_mut() {
        let reversion = (RATE_NEUTRAL - rate) * 0.08;
        let drift = rng.gen_range(-0.0035_f64..0.0035_f64);
        let shock = if rng.r#gen::<f64>() < 1.0 / 12.0 {
            let choices = [-0.018_f64, -0.012, 0.012, 0.018];
            choices[rng.gen_range(0..4)]
        } else { 0.0 };
        rate = (rate + reversion + drift + shock).clamp(RATE_FLOOR, RATE_CEILING);
        *slot = rate;
    }
    path
}

struct CrashProfile { prob_per_yr: f64, mag_lo: f64, mag_hi: f64, recovery_yrs: u32 }

fn crash_profile(loc: &str) -> CrashProfile {
    match loc {
        "PERTH"           => CrashProfile { prob_per_yr: 0.04,  mag_lo: 0.08, mag_hi: 0.18, recovery_yrs: 5 },
        "GERALDTON_HOUSE" => CrashProfile { prob_per_yr: 0.035, mag_lo: 0.10, mag_hi: 0.25, recovery_yrs: 6 },
        "GERALDTON_LAND"  => CrashProfile { prob_per_yr: 0.035, mag_lo: 0.10, mag_hi: 0.25, recovery_yrs: 6 },
        "CANBERRA"        => CrashProfile { prob_per_yr: 0.015, mag_lo: 0.05, mag_hi: 0.12, recovery_yrs: 4 },
        _                 => CrashProfile { prob_per_yr: 0.0,   mag_lo: 0.0,  mag_hi: 0.0,  recovery_yrs: 1 },
    }
}

struct CrashState { in_recovery: bool, magnitude: f64, yrs_since: u32 }

fn apply_property_shock(rng: &mut SmallRng, loc: &str, s: &mut CrashState) -> f64 {
    let p = crash_profile(loc);
    if s.in_recovery {
        s.yrs_since += 1;
        if s.yrs_since >= p.recovery_yrs {
            s.in_recovery = false; s.magnitude = 0.0; s.yrs_since = 0;
            return 1.0;
        }
        return 1.0 - s.magnitude * (1.0 - s.yrs_since as f64 / p.recovery_yrs as f64);
    }
    if rng.r#gen::<f64>() < p.prob_per_yr {
        s.magnitude = rng.gen_range(p.mag_lo..p.mag_hi);
        s.in_recovery = true; s.yrs_since = 0;
        return 1.0 - s.magnitude;
    }
    1.0
}

struct Location { price_now: f64, growth: f64 }

fn location(key: &str) -> Location {
    match key {
        // Perth median Apr 2026: $1,087,507 — use $1,000,000 as conservative base (mid-2025)
        // Growth: 5% long-run (up from 4.5%; recent boom was 12-15%/yr but unsustainable)
        "GERALDTON_HOUSE" => Location { price_now: 550000.0, growth: 0.035 },
        // Geraldton land: REIWA Oct 2025 releases show $65k-$200k range; conservative mid-market
        "GERALDTON_LAND"  => Location { price_now: 175000.0, growth: 0.035 },
        "PERTH"           => Location { price_now: 1000000.0, growth: 0.050 },
        // Canberra median mid-2025: ~$1,040,000; modest growth given 2022-23 correction
        "CANBERRA"        => Location { price_now: 1040000.0, growth: 0.030 },
        _                 => Location { price_now: 0.0, growth: 0.0 },
    }
}

// WA Transfer Duty (standard tiered rates, effective 2025)
// Source: RevenueWA + Proplyx stamp duty guide
fn wa_stamp_duty(price: f64) -> f64 {
    if price <= 120000.0 {
        price * 0.019
    } else if price <= 150000.0 {
        2280.0 + (price - 120000.0) * 0.0285
    } else if price <= 360000.0 {
        3135.0 + (price - 150000.0) * 0.0475
    } else if price <= 725000.0 {
        17942.50 + (price - 360000.0) * 0.065
    } else if price <= 1000000.0 {
        44077.50 + (price - 725000.0) * 0.075
    } else {
        75077.50 + (price - 1000000.0) * 0.085
    }
}

// WA First Home Buyer concession (from 21 Mar 2025): full exemption ≤ $500k,
// then concessional rate to $700k metro / $750k regional
fn wa_stamp_duty_fhb(price: f64, is_regional: bool) -> f64 {
    let ceiling = if is_regional { 750000.0 } else { 700000.0 };
    let conc_rate = if is_regional { 0.1189 } else { 0.1363 };
    if price <= 500000.0 {
        0.0
    } else if price <= ceiling {
        (price - 500000.0) * conc_rate
    } else {
        wa_stamp_duty(price) // over ceiling: no concession, pay full duty
    }
}

// ACT conveyance duty approximation (tiered, simpler than WA but similar ballpark at $1M)
fn act_stamp_duty(price: f64) -> f64 {
    // ACT residential conveyance duty tiers (2025-26 approximation)
    if price <= 200000.0 {
        price * 0.0120
    } else if price <= 300000.0 {
        2400.0 + (price - 200000.0) * 0.0229
    } else if price <= 500000.0 {
        4690.0 + (price - 300000.0) * 0.0314
    } else if price <= 750000.0 {
        10970.0 + (price - 500000.0) * 0.0383
    } else if price <= 1000000.0 {
        20545.0 + (price - 750000.0) * 0.0435
    } else if price <= 1455000.0 {
        31420.0 + (price - 1000000.0) * 0.0547
    } else {
        56316.0 + (price - 1455000.0) * 0.0490
    }
}

fn stamp_duty(price: f64, location_key: &str, is_first_home: bool) -> f64 {
    match location_key {
        "PERTH" => {
            if is_first_home { wa_stamp_duty_fhb(price, false) } else { wa_stamp_duty(price) }
        }
        "GERALDTON_HOUSE" | "GERALDTON_LAND" => {
            if is_first_home { wa_stamp_duty_fhb(price, true) } else { wa_stamp_duty(price) }
        }
        "CANBERRA" => act_stamp_duty(price),
        _ => 0.0,
    }
}

struct Volatility { job_loss_prob: f64, job_loss_impact: f64, promo_jitter: f64 }

fn volatility(key: &str) -> Volatility {
    match key {
        "FIFO_EI"               => Volatility { job_loss_prob: 0.04,  job_loss_impact: 0.35, promo_jitter: 0.06 },
        "RF_SATELLITE_ENG"      => Volatility { job_loss_prob: 0.02,  job_loss_impact: 0.55, promo_jitter: 0.05 },
        "RAAF_TECH_OFFICER"     => Volatility { job_loss_prob: 0.005, job_loss_impact: 0.85, promo_jitter: 0.03 },
        "LOCAL_TRADE_GLD"       => Volatility { job_loss_prob: 0.03,  job_loss_impact: 0.50, promo_jitter: 0.04 },
        "ENG_CANBERRA"          => Volatility { job_loss_prob: 0.015, job_loss_impact: 0.60, promo_jitter: 0.05 },
        "FIFO_SUPERVISOR"       => Volatility { job_loss_prob: 0.035, job_loss_impact: 0.40, promo_jitter: 0.06 },
        "RESIDENTIAL_MINING"    => Volatility { job_loss_prob: 0.05,  job_loss_impact: 0.40, promo_jitter: 0.05 },
        "BRIDGE_TO_ENG"         => Volatility { job_loss_prob: 0.025, job_loss_impact: 0.55, promo_jitter: 0.05 },
        "SOFTWARE_ENG"          => Volatility { job_loss_prob: 0.04,  job_loss_impact: 0.50, promo_jitter: 0.07 },
        "CYBERSECURITY"         => Volatility { job_loss_prob: 0.025, job_loss_impact: 0.55, promo_jitter: 0.06 },
        "AEROSPACE_ENG"         => Volatility { job_loss_prob: 0.02,  job_loss_impact: 0.55, promo_jitter: 0.05 },
        "MECHATRONICS_ROBOTICS" => Volatility { job_loss_prob: 0.03,  job_loss_impact: 0.50, promo_jitter: 0.06 },
        "DATA_AI_ENGINEERING"   => Volatility { job_loss_prob: 0.035, job_loss_impact: 0.50, promo_jitter: 0.07 },
        // Mining/petroleum FIFO: cyclical commodity exposure, project-based contracts
        "MINING_ENG_FIFO"      => Volatility { job_loss_prob: 0.04,  job_loss_impact: 0.40, promo_jitter: 0.06 },
        // Oil & gas most volatile sector in WA; bust cycles (2014-16, 2020) cause sharp contractions
        "PETROLEUM_ENG_FIFO"   => Volatility { job_loss_prob: 0.05,  job_loss_impact: 0.40, promo_jitter: 0.07 },
        // Security architects: high demand, below-average layoff risk vs general software
        "SECURITY_ARCHITECT"   => Volatility { job_loss_prob: 0.02,  job_loss_impact: 0.55, promo_jitter: 0.06 },
        // Cloud architects: tech-sector layoff exposure but cloud demand is structural
        "CLOUD_SOLUTIONS_ARCH" => Volatility { job_loss_prob: 0.03,  job_loss_impact: 0.50, promo_jitter: 0.07 },
        // FIFO electrical engineer: same commodity-cycle exposure as mining FIFO; salaried more stable than contract
        "FIFO_ELEC_ENG"        => Volatility { job_loss_prob: 0.04,  job_loss_impact: 0.40, promo_jitter: 0.06 },
        // FIFO I&C: specialist skill scarcity means lower layoff risk and faster re-hire; sector demand structural
        "FIFO_IC_ENG"          => Volatility { job_loss_prob: 0.03,  job_loss_impact: 0.40, promo_jitter: 0.06 },
        _                       => Volatility { job_loss_prob: 0.03,  job_loss_impact: 0.50, promo_jitter: 0.05 },
    }
}

// Rent and living costs by location (2026 research data)
// Rent: single-person rate (share house / 1-bed unit) as of mid-2026
// REIWA Jan 2026: Perth median 1-bed unit $560/wk; share house room $300-420/wk → use $460/wk
// Canberra: comparable unit market, slightly lower than Perth → $470/wk
// Geraldton: regional, ~$330/wk unchanged (limited REIWA data, conservative)
fn location_costs(loc: &str) -> (f64, f64) {
    match loc {
        "CANBERRA" => (470.0 * 52.0, 15000.0),
        "PERTH"    => (460.0 * 52.0, 12000.0),
        "NONE"     => (460.0 * 52.0, 12000.0), // rent-forever assumes Perth work location
        _          => (330.0 * 52.0, 10500.0), // Geraldton/regional
    }
}

fn get_costs(yr: u32, has_uni_gap_yr: bool, owns_prop: bool, strategy: &str, loc: &str) -> (f64, f64) {
    if yr == 1 { return (5200.0, 4200.0); }
    // Uni years: student in Perth share house regardless of planned purchase location
    if yr <= 5 && has_uni_gap_yr { return (270.0 * 52.0, 10500.0); }
    let (rent, living) = location_costs(loc);
    if strategy == "RENT_FOREVER" || !owns_prop { return (rent, living); }
    (0.0, living)
}

// ATO-eligible work-related deductions by career category.
// FIFO: fly-back travel, work clothing/PPE, tools, union fees, technical subscriptions.
// Trade: tools, licensing, safety equipment, professional development.
// Tech/engineering: home office, equipment depreciation, professional memberships.
// Source: ATO Tax Stats 2022-23 median claims by occupation; FIFO premium from CFMEU/ETU guides.
fn work_deductions(path_key: &str) -> f64 {
    match path_key {
        "FIFO_EI" | "FIFO_SUPERVISOR" | "FIFO_ELEC_ENG" |
        "FIFO_IC_ENG" | "MINING_ENG_FIFO" | "PETROLEUM_ENG_FIFO" => 7_000.0,
        "LOCAL_TRADE_GLD" | "RESIDENTIAL_MINING"                   => 4_500.0,
        "BRIDGE_TO_ENG"                                             => 3_500.0,
        "SOFTWARE_ENG" | "DATA_AI_ENGINEERING" | "CYBERSECURITY" |
        "SECURITY_ARCHITECT" | "CLOUD_SOLUTIONS_ARCH"              => 2_500.0,
        "RAAF_TECH_OFFICER"                                         => 1_800.0,
        _                                                           => 2_200.0,
    }
}

fn last_income_val(sched: &Schedule) -> f64 {
    sched.values().cloned().fold(f64::NEG_INFINITY, f64::max)
}

struct RunResult {
    final_nw: f64,
    liquid: f64,
    sup: f64,
    prop_val: f64,
    mort_bal: f64,
    debt_free_age: Option<u32>,
    actual_buy_age: Option<u32>,
    nw35: f64,
    nw40: f64,
    nw45: f64,
}

fn simulate_once(
    base_income: &Schedule,
    last_income: f64,
    hecs_total: f64,
    path_key: &str,
    location_key: &str,
    strategy: &str,
    sell_age: Option<u32>,
    deposit_pct: f64,
    savings_rate: f64,
    buy_age: u32,
    rng: &mut SmallRng,
) -> RunResult {
    let vol = volatility(path_key);
    let loc = location(location_key);
    let has_uni_gap_yr = base_income.get(&1).copied().unwrap_or(0.0) == 57304.0;
    let rate_path = generate_rate_path(rng);

    let mut hecs_rem = hecs_total;
    let mut cum_liq = 0.0_f64;
    let mut sup = 0.0_f64;
    let mut mort_bal = 0.0_f64;
    let mut prop_val = 0.0_f64;
    let mut prop_val_unshocked = 0.0_f64;
    let mut bought = false;
    let buy_yr = buy_age - 17;
    let mut actual_buy_age: Option<u32> = None;
    let mut actual_buy_yr: u32 = 0;
    let mut land_val = 0.0_f64;
    let mut house_val = 0.0_f64;
    let mut build_done = false;
    let mut sold = false;
    let mut debt_free_age: Option<u32> = None;
    let is_land = strategy == "BUY_LAND_BUILD";
    let is_mining_loc = matches!(location_key, "PERTH" | "GERALDTON_HOUSE" | "GERALDTON_LAND");
    let mut income_gap_active = false;
    let mut crash = CrashState { in_recovery: false, magnitude: 0.0, yrs_since: 0 };
    let mut crash_boost_remaining: u32 = 0; // elevated job-loss years after a commodity-sector crash
    let mut remaining_years: u32 = 1;
    let mut nw35 = 0.0_f64;
    let mut nw40 = 0.0_f64;
    let mut nw45 = 0.0_f64;
    let mut sold_yr: u32 = 0;         // year PPOR was sold; triggers Geraldton build tracking
    let mut gld_build_pending: f64 = 0.0; // build cost deferred until 2 yrs after PPOR sale

    for yr in 1u32..=33 {
        let age = 17 + yr;
        let current_rate = rate_path[(yr - 1) as usize];

        // Savings return: portion offset against outstanding mortgage earns mortgage rate
        // (offset account saves mortgage interest, net of HISA return already earned).
        // Negative balance: debt accrues at personal-loan rate — no free overdraft.
        if cum_liq >= 0.0 {
            let offset_bonus = if mort_bal > 0.0 {
                cum_liq.min(mort_bal) * (current_rate - SAVINGS_RETURN)
            } else { 0.0 };
            cum_liq = cum_liq * (1.0 + SAVINGS_RETURN) + offset_bonus;
        } else {
            cum_liq *= 1.0 + DEBT_RATE;
        }
        let scripted_g = base_income.get(&yr).copied().unwrap_or(last_income);

        // Correlated crash→job-loss boost: if a commodity-sector crash is still rippling,
        // double the annual job-loss probability for mining-exposed locations.
        let effective_job_loss_prob = if crash_boost_remaining > 0 && is_mining_loc {
            vol.job_loss_prob * 2.0
        } else {
            vol.job_loss_prob
        };
        if crash_boost_remaining > 0 { crash_boost_remaining -= 1; }

        let jitter = 1.0 + rng.gen_range(-vol.promo_jitter..vol.promo_jitter);
        let mut g = scripted_g * jitter;

        if yr >= 6 {
            if income_gap_active {
                // Recovery year: partial income restoration; floor at JobSeeker
                // JobSeeker single no-children Mar-2026: $808.70/fn × 26 = $21,026/yr
                g = (g * vol.job_loss_impact * 1.3).max(JOBSEEKER_ANNUAL);
                income_gap_active = false;
            } else if rng.r#gen::<f64>() < effective_job_loss_prob {
                // Loss year: income hit; floor at JobSeeker welfare payment
                g = (g * vol.job_loss_impact).max(JOBSEEKER_ANNUAL);
                income_gap_active = true;
            }
        }
        g = g.max(0.0);

        // RAAF/ADF employer super rate is 16.4%, vs civilian SG of 12%
        let effective_super_rate = if path_key == "RAAF_TECH_OFFICER" { RAAF_SUPER_RATE } else { SUPER_RATE };
        // Concessional cap: $30k/yr combined employer + salary sacrifice (2024-25 limit)
        let super_contrib = (g * effective_super_rate).min(SUPER_CONC_CAP);
        // Division 293: additional 15% tax on concessional contributions for income > $250k;
        // charged to the super fund (ATO bill paid from balance). Affects peak FIFO/petroleum earners.
        let d293 = if g > 250_000.0 { super_contrib * 0.15 } else { 0.0 };
        // Super return drawn from N(7.5%, 11.5²%) — matches 30-yr AustralianSuper balanced data
        let super_return = sample_super_return(rng);
        // SUPER_FEE_RATE: admin + insurance fees charged as % of balance (above net return).
        // Applied after return and before new contributions so it compounds correctly.
        sup = sup * (1.0 + super_return) * (1.0 - SUPER_FEE_RATE) + super_contrib * (1.0 - SUPER_TAX) - d293;

        // Work-related deductions reduce taxable income (tools, PPE, union fees, home office etc.)
        // Only in working years (yr >= 6); deductions don't reduce gross income, only tax liability.
        let deductions = if yr >= 6 { work_deductions(path_key) } else { 0.0 };
        let taxable_g = (g - deductions).max(0.0);
        let tax = calc_tax(taxable_g);

        // HECS accrual (years 2–5) then annual CPI indexation before repayment
        // Indexation capped at lower of CPI or WPI per Education Amendment Act 2024;
        // 2025 rate = 3.2%; long-run proxy = 3%
        if yr >= 2 && yr <= 5 && hecs_total > 0.0 {
            hecs_rem = f64::min(hecs_total, (yr - 1) as f64 * (hecs_total / 4.0));
        }
        if hecs_rem > 0.0 {
            hecs_rem *= 1.0 + HECS_INDEXATION;
        }
        let hp = if hecs_rem > 0.0 && taxable_g >= 67000.0 {
            let h = calc_hecs(taxable_g, hecs_rem);
            hecs_rem = f64::max(0.0, hecs_rem - h);
            h
        } else { 0.0 };
        let net = g - tax - hp;

        let (rent, living) = get_costs(yr, has_uni_gap_yr, bought && !sold, strategy, location_key);
        // Rent and living costs inflate at different rates:
        // Perth/Canberra rents track property appreciation (4.5%/3.5%/yr);
        // regional rents and all living costs grow at CPI (3%/yr)
        let (rent, living) = if yr >= 9 {
            let rent_inf  = (1.0 + rent_inflation_rate(location_key)).powi((yr - 8) as i32);
            let liv_inf   = (1.0 + COST_INFLATION).powi((yr - 8) as i32);
            (rent * rent_inf, living * liv_inf)
        } else { (rent, living) };

        let mut loan_repay = 0.0_f64;

        if strategy != "RENT_FOREVER" && location_key != "NONE" {
            if yr >= buy_yr && !bought {
                let price_at_buy = loc.price_now * (1.0 + loc.growth).powi(yr as i32);
                let dep = price_at_buy * deposit_pct;
                // First purchase is always FHB; use proper WA tiered duty (or ACT approx)
                let sd = stamp_duty(price_at_buy, location_key, true);
                if cum_liq >= dep + sd + ACQUISITION_COSTS + 50000.0 {
                    cum_liq -= dep + sd + ACQUISITION_COSTS;
                    let base_loan = price_at_buy - dep;
                    let lmi = calc_lmi(base_loan, deposit_pct); // capitalised onto loan
                    mort_bal = base_loan + lmi;
                    remaining_years = if is_land { 5 } else { 30 };
                    prop_val_unshocked = price_at_buy;
                    land_val = if is_land { price_at_buy } else { 0.0 };
                    bought = true;
                    actual_buy_age = Some(age);
                    actual_buy_yr = yr;
                }
            }

            if bought && !sold && mort_bal > 0.0 {
                let annual_repay = loan_pmt_annual(mort_bal, current_rate, remaining_years.max(1));
                let interest = mort_bal * current_rate;
                let principal = f64::min(annual_repay - interest, mort_bal);
                mort_bal = f64::max(0.0, mort_bal - principal);
                loan_repay = annual_repay;
                remaining_years = remaining_years.saturating_sub(1).max(1);
                if mort_bal <= 1.0 {
                    mort_bal = 0.0;
                    if debt_free_age.is_none() && !is_land {
                        debt_free_age = Some(age);
                    }
                }
            }

            if bought && !sold {
                let was_not_recovering = !crash.in_recovery;
                let crash_mult = apply_property_shock(rng, location_key, &mut crash);
                // If a new crash just triggered in a mining-exposed location, elevate job-loss
                // risk for the next 2 years (commodity busts cause correlated layoffs).
                if was_not_recovering && crash.in_recovery && is_mining_loc {
                    crash_boost_remaining = crash_boost_remaining.max(2);
                }
                if is_land {
                    let land_fund = loc.price_now * (1.0 + loc.growth).powi((yr - actual_buy_yr + 1) as i32);
                    land_val = land_fund * crash_mult;
                    if mort_bal == 0.0 && !build_done && yr >= actual_buy_yr + 6 {
                        let build_rate = BUILD_RATE_NOW * (1.0 + BUILD_INFLATION).powi(yr as i32);
                        // Regional WA construction routinely runs 0–30% over budget:
                        // supply-chain delays, subcontractor scarcity, council approval overruns.
                        let overrun = 1.0 + rng.gen_range(0.0_f64..0.30_f64);
                        let build_cost = 200.0 * build_rate * 1.38 * overrun;
                        if cum_liq >= build_cost + 50000.0 {
                            cum_liq -= build_cost;
                            house_val = build_cost;
                            build_done = true;
                            debt_free_age = Some(age);
                        }
                    }
                    prop_val = land_val + house_val;
                } else {
                    prop_val_unshocked = loc.price_now * (1.0 + loc.growth).powi((yr - actual_buy_yr + 1) as i32);
                    prop_val = prop_val_unshocked * crash_mult;
                }
            }

            if strategy == "BUY_HOLD" && bought && !sold && sell_age.map_or(false, |sa| age == sa) {
                let sale_costs = prop_val * 0.025 + 12500.0;
                cum_liq += prop_val - mort_bal - sale_costs; // PPOR: no CGT
                let gld_base = 175_000.0_f64;
                let gld_price = gld_base * 1.035_f64.powi(yr as i32);
                let gld_stamp = wa_stamp_duty(gld_price);
                let build_rate = BUILD_RATE_NOW * (1.0 + BUILD_INFLATION).powi(yr as i32);
                let overrun = 1.0 + rng.gen_range(0.0_f64..0.30_f64);
                gld_build_pending = 200.0 * build_rate * 1.38 * overrun;
                cum_liq -= gld_price + gld_stamp; // land purchased now; builder paid on completion
                prop_val = gld_price;             // land value only during ~2-year construction
                mort_bal = 0.0;
                sold = true;
                sold_yr = yr;
                // debt_free_age is set when construction completes (yr + 2)
            }

            // Post-sell Geraldton tracking: land appreciates during construction; build paid on completion.
            // Person pays rent each year (get_costs returns rent when !owns_prop) while this runs.
            if bought && sold && sold_yr > 0 {
                let yrs = yr - sold_yr;
                let land_now = 175_000.0_f64 * 1.035_f64.powi(yr as i32);
                if yrs < 2 {
                    prop_val = land_now;
                } else if gld_build_pending > 0.0 {
                    // Build proceeds when person has enough savings, or after 7 years (forced completion).
                    // Prevents entering explosive debt spiral from an unfeasible early-sell scenario.
                    let can_afford = cum_liq >= gld_build_pending - 150_000.0;
                    let must_build = yrs >= 7;
                    if can_afford || must_build {
                        cum_liq -= gld_build_pending;
                        prop_val = land_now + gld_build_pending;
                        gld_build_pending = 0.0;
                        if debt_free_age.is_none() { debt_free_age = Some(age); }
                    } else {
                        prop_val = land_now; // still saving; continue renting
                    }
                } else {
                    prop_val *= 1.0 + 0.035; // Geraldton growth on completed property
                }
            }
        }

        // Ongoing property costs: council rates, water, building insurance, maintenance reserve.
        // Applied to current property value whether PPOR or post-sell Geraldton property.
        let prop_holding = if prop_val > 0.0 { prop_val * PROP_HOLDING_RATE } else { 0.0 };
        let delta = net - rent - living - loan_repay - prop_holding;
        cum_liq += if delta >= 0.0 { delta * savings_rate } else { delta };

        let cur_nw = cum_liq + sup + prop_val - mort_bal;
        if age == 35 { nw35 = cur_nw; }
        if age == 40 { nw40 = cur_nw; }
        if age == 45 { nw45 = cur_nw; }
    }

    RunResult {
        final_nw: (cum_liq + sup + prop_val - mort_bal).round(),
        liquid: cum_liq.round(),
        sup: sup.round(),
        prop_val: prop_val.round(),
        mort_bal: mort_bal.round(),
        debt_free_age,
        actual_buy_age,
        nw35: nw35.round(),
        nw40: nw40.round(),
        nw45: nw45.round(),
    }
}

fn percentile(sorted: &[f64], p: f64) -> f64 {
    let idx = ((p / 100.0 * (sorted.len() - 1) as f64).round() as usize).min(sorted.len() - 1);
    sorted[idx]
}

fn median_sorted(sorted: &[f64]) -> f64 {
    let n = sorted.len();
    if n == 0 { return 0.0; }
    if n % 2 == 0 { (sorted[n/2 - 1] + sorted[n/2]) / 2.0 } else { sorted[n/2] }
}

struct ScenarioRow {
    career_path: String,
    location: String,
    strategy: String,
    sell_age: String,
    deposit_pct: f64,
    savings_rate: f64,
    requested_buy_age: String,
    pct_scenarios_bought_property: f64,
    median_actual_buy_age: String,
    pct_debt_free_by_50: f64,
    median_debt_free_age: String,
    net_worth_p10_age50: f64,
    net_worth_p50_age50: f64,
    net_worth_p90_age50: f64,
    net_worth_range_age50: f64,
    median_liquid_age50: f64,
    median_super_age50: f64,
    median_property_value_age50: f64,
    median_mortgage_remaining_age50: f64,
    net_worth_p50_age35: f64,
    net_worth_p50_age40: f64,
    net_worth_p50_age45: f64,
}

fn simulate_scenario(
    path_key: &str, path_name: &str, base_income: &Schedule, last_income: f64, hecs_total: f64,
    location_key: &str, strategy: &str, sell_age: Option<u32>,
    deposit_pct: f64, savings_rate: f64, buy_age: u32, seed_base: u64,
) -> ScenarioRow {
    let results: Vec<RunResult> = (0..N_RUNS).map(|i| {
        let mut rng = SmallRng::seed_from_u64(seed_base + i as u64);
        simulate_once(base_income, last_income, hecs_total, path_key, location_key, strategy,
                      sell_age, deposit_pct, savings_rate, buy_age, &mut rng)
    }).collect();

    let mut nws: Vec<f64> = results.iter().map(|r| r.final_nw).collect();
    nws.sort_by(|a, b| a.partial_cmp(b).unwrap());

    let df: Vec<f64> = results.iter().filter_map(|r| r.debt_free_age.map(|a| a as f64)).collect();
    let pct_df = 100.0 * df.len() as f64 / N_RUNS as f64;
    let mut df_s = df.clone(); df_s.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let med_df = if df_s.is_empty() { "Not by 50".into() } else { median_sorted(&df_s).to_string() };

    let ba: Vec<f64> = results.iter().filter_map(|r| r.actual_buy_age.map(|a| a as f64)).collect();
    let pct_b = 100.0 * ba.len() as f64 / N_RUNS as f64;
    let mut ba_s = ba.clone(); ba_s.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let med_ba = if ba_s.is_empty() { "Never".into() } else { median_sorted(&ba_s).round().to_string() };

    let sort_f = |v: &mut Vec<f64>| v.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let mut liq: Vec<f64> = results.iter().map(|r| r.liquid).collect();   sort_f(&mut liq);
    let mut sup: Vec<f64> = results.iter().map(|r| r.sup).collect();      sort_f(&mut sup);
    let mut prp: Vec<f64> = results.iter().map(|r| r.prop_val).collect(); sort_f(&mut prp);
    let mut mrt: Vec<f64> = results.iter().map(|r| r.mort_bal).collect(); sort_f(&mut mrt);
    let mut nw35s: Vec<f64> = results.iter().map(|r| r.nw35).collect(); sort_f(&mut nw35s);
    let mut nw40s: Vec<f64> = results.iter().map(|r| r.nw40).collect(); sort_f(&mut nw40s);
    let mut nw45s: Vec<f64> = results.iter().map(|r| r.nw45).collect(); sort_f(&mut nw45s);

    let no_prop = strategy == "RENT_FOREVER" || location_key == "NONE";
    ScenarioRow {
        career_path: path_name.into(),
        location: location_key.into(),
        strategy: strategy.into(),
        sell_age: sell_age.map_or("N/A".into(), |a| a.to_string()),
        deposit_pct,
        savings_rate,
        requested_buy_age: if no_prop { "N/A".into() } else { buy_age.to_string() },
        pct_scenarios_bought_property: if no_prop { 0.0 } else { pct_b },
        median_actual_buy_age: med_ba,
        pct_debt_free_by_50: pct_df,
        median_debt_free_age: med_df,
        net_worth_p10_age50: percentile(&nws, 10.0),
        net_worth_p50_age50: percentile(&nws, 50.0),
        net_worth_p90_age50: percentile(&nws, 90.0),
        net_worth_range_age50: percentile(&nws, 90.0) - percentile(&nws, 10.0),
        median_liquid_age50: median_sorted(&liq),
        median_super_age50: median_sorted(&sup),
        median_property_value_age50: median_sorted(&prp),
        median_mortgage_remaining_age50: median_sorted(&mrt),
        net_worth_p50_age35: median_sorted(&nw35s),
        net_worth_p50_age40: median_sorted(&nw40s),
        net_worth_p50_age45: median_sorted(&nw45s),
    }
}

fn row_fields(r: &ScenarioRow) -> [String; 22] {
    [
        r.career_path.clone(), r.location.clone(), r.strategy.clone(), r.sell_age.clone(),
        r.deposit_pct.to_string(), r.savings_rate.to_string(),
        r.requested_buy_age.clone(), r.pct_scenarios_bought_property.to_string(),
        r.median_actual_buy_age.clone(), r.pct_debt_free_by_50.to_string(),
        r.median_debt_free_age.clone(),
        r.net_worth_p10_age50.to_string(), r.net_worth_p50_age50.to_string(),
        r.net_worth_p90_age50.to_string(), r.net_worth_range_age50.to_string(),
        r.median_liquid_age50.to_string(), r.median_super_age50.to_string(),
        r.median_property_value_age50.to_string(), r.median_mortgage_remaining_age50.to_string(),
        r.net_worth_p50_age35.to_string(), r.net_worth_p50_age40.to_string(),
        r.net_worth_p50_age45.to_string(),
    ]
}

fn write_csv(path: &str, rows: &[ScenarioRow]) -> csv::Result<()> {
    let mut w = csv::Writer::from_path(path)?;
    w.write_record([
        "career_path","location","strategy","sell_age","deposit_pct","savings_rate",
        "requested_buy_age","pct_scenarios_bought_property","median_actual_buy_age",
        "pct_debt_free_by_50","median_debt_free_age",
        "net_worth_p10_age50","net_worth_p50_age50","net_worth_p90_age50",
        "net_worth_range_age50","median_liquid_age50","median_super_age50",
        "median_property_value_age50","median_mortgage_remaining_age50",
        "net_worth_p50_age35","net_worth_p50_age40","net_worth_p50_age45",
    ])?;
    for r in rows { w.write_record(row_fields(r))?; }
    w.flush()?; Ok(())
}

fn write_leaderboard(path: &str, rows: &[ScenarioRow]) -> csv::Result<()> {
    let mut w = csv::Writer::from_path(path)?;
    w.write_record([
        "leaderboard_category",
        "career_path","location","strategy","sell_age","deposit_pct","savings_rate",
        "requested_buy_age","pct_scenarios_bought_property","median_actual_buy_age",
        "pct_debt_free_by_50","median_debt_free_age",
        "net_worth_p10_age50","net_worth_p50_age50","net_worth_p90_age50",
        "net_worth_range_age50","median_liquid_age50","median_super_age50",
        "median_property_value_age50","median_mortgage_remaining_age50",
        "net_worth_p50_age35","net_worth_p50_age40","net_worth_p50_age45",
    ])?;

    let write_cat = |w: &mut csv::Writer<_>, cat: &str, r: &ScenarioRow| -> csv::Result<()> {
        let mut rec = vec![cat.to_string()];
        rec.extend(row_fields(r));
        w.write_record(&rec)
    };

    let mut by_wealth: Vec<&ScenarioRow> = rows.iter().collect();
    by_wealth.sort_by(|a, b| b.net_worth_p50_age50.partial_cmp(&a.net_worth_p50_age50).unwrap());
    for r in by_wealth.iter().take(10) { write_cat(&mut w, "TOP 10 — Max Wealth (P50)", r)?; }

    let mut df_rows: Vec<&ScenarioRow> = rows.iter()
        .filter(|r| r.median_debt_free_age != "Not by 50").collect();
    df_rows.sort_by(|a, b| {
        a.median_debt_free_age.parse::<f64>().unwrap_or(99.0)
            .partial_cmp(&b.median_debt_free_age.parse::<f64>().unwrap_or(99.0)).unwrap()
    });
    for r in df_rows.iter().take(10) { write_cat(&mut w, "TOP 10 — Fastest Debt Free", r)?; }

    let mut nws: Vec<f64> = rows.iter().map(|r| r.net_worth_p50_age50).collect();
    nws.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let med_nw = median_sorted(&nws);
    let mut reliable: Vec<&ScenarioRow> = rows.iter()
        .filter(|r| r.net_worth_p50_age50 >= med_nw).collect();
    reliable.sort_by(|a, b| a.net_worth_range_age50.partial_cmp(&b.net_worth_range_age50).unwrap());
    for r in reliable.iter().take(10) {
        write_cat(&mut w, "TOP 10 — Most Reliable (low variance, above-median wealth)", r)?;
    }

    let mut by_p10: Vec<&ScenarioRow> = rows.iter().collect();
    by_p10.sort_by(|a, b| b.net_worth_p10_age50.partial_cmp(&a.net_worth_p10_age50).unwrap());
    for r in by_p10.iter().take(10) { write_cat(&mut w, "TOP 10 — Best Worst-Case (highest P10)", r)?; }

    w.flush()?; Ok(())
}

const LOCATION_KEYS: &[&str]      = &["GERALDTON_HOUSE", "GERALDTON_LAND", "PERTH", "CANBERRA", "NONE"];
const STRATEGIES: &[&str]         = &["BUY_HOLD", "BUY_LAND_BUILD", "RENT_FOREVER"];
const SELL_AGE_OPTS: &[Option<u32>] = &[None, Some(30), Some(32), Some(35), Some(40), Some(45), Some(50)];
const DEPOSIT_PCTS: &[f64]        = &[0.10, 0.20, 0.50];
const SAVINGS_RATES: &[f64]       = &[0.55, 0.65, 0.75, 0.85, 0.90];
const BUY_AGES: &[u32]            = &[23, 25, 28];

fn main() {
    let out_dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
    std::fs::create_dir_all(&out_dir).expect("cannot create output dir");

    let paths = all_paths();
    let path_map: HashMap<&str, (Schedule, f64, f64, &str)> = paths.iter().map(|(key, name, fn_)| {
        let (sched, hecs) = fn_();
        let last = last_income_val(&sched);
        (*key, (sched, hecs, last, *name))
    }).collect();

    // Build scenario parameter list
    let mut scenarios: Vec<(&str, &str, &str, &str, Option<u32>, f64, f64, u32, u64)> = Vec::new();
    let mut seed: u64 = 1000;
    for (path_key, path_name, _) in &paths {
        for &loc in LOCATION_KEYS {
            for &strat in STRATEGIES {
                if strat == "BUY_LAND_BUILD" && loc != "GERALDTON_LAND" { continue; }
                if strat == "BUY_HOLD" && (loc == "NONE" || loc == "GERALDTON_LAND") { continue; }
                if strat == "RENT_FOREVER" && loc != "NONE" { continue; }
                if strat != "RENT_FOREVER" && loc == "NONE" { continue; }

                let sell_opts: &[Option<u32>] = if strat == "BUY_HOLD" { SELL_AGE_OPTS } else { &[None] };
                for &sell in sell_opts {
                    let dep_opts: &[f64] = if strat != "RENT_FOREVER" { DEPOSIT_PCTS } else { &[0.0] };
                    for &dep in dep_opts {
                        for &sr in SAVINGS_RATES {
                            let buy_opts: &[u32] = if strat != "RENT_FOREVER" { BUY_AGES } else { &[0] };
                            for &ba in buy_opts {
                                if sell.map_or(false, |sa| sa <= ba + 3) { continue; }
                                seed += 1;
                                scenarios.push((path_key, path_name, loc, strat, sell, dep, sr, ba, seed));
                            }
                        }
                    }
                }
            }
        }
    }

    println!("Generated {} scenarios × {} Monte Carlo runs each = {} total simulations",
             scenarios.len(), N_RUNS, scenarios.len() * N_RUNS);

    let rows: Vec<ScenarioRow> = scenarios.par_iter().map(|&(pk, pn, loc, strat, sell, dep, sr, ba, seed)| {
        let (sched, hecs, last, _) = path_map.get(pk).unwrap();
        simulate_scenario(pk, pn, sched, *last, *hecs, loc, strat, sell, dep, sr, ba, seed)
    }).collect();

    write_csv(&format!("{}/career_permutations_v2.csv", out_dir), &rows)
        .expect("failed to write main CSV");
    println!("Written career_permutations_v2.csv");

    write_leaderboard(&format!("{}/leaderboard.csv", out_dir), &rows)
        .expect("failed to write leaderboard CSV");
    println!("Written leaderboard.csv");
}
