#!/usr/bin/env python3
"""
Burn Rate & Runway Calculator
==============================
Models startup runway across base/bull/bear scenarios, incorporating
a hiring plan and revenue trajectory. Outputs months of runway,
cash-out dates, and decision trigger points.

Usage:
    python burn_rate_calculator.py
    python burn_rate_calculator.py --csv  # export to CSV

Stdlib only. No dependencies.
"""

import argparse
import csv
import io
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Optional


# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------

@dataclass
class HiringEntry:
    """A planned hire."""
    month: int          # months from model start (1-indexed)
    role: str
    department: str     # "sales", "engineering", "cs", "ga"
    annual_salary: float
    benefits_pct: float = 0.22  # benefits as % of salary
    recruiting_cost: float = 0.0  # one-time recruiting fee


@dataclass
class RevenueEntry:
    """Monthly revenue data point (historical or projected)."""
    month: int
    mrr: float          # monthly recurring revenue
    one_time: float = 0.0


@dataclass
class ModelConfig:
    """Master configuration for a runway scenario."""
    name: str
    starting_cash: float
    starting_mrr: float
    starting_headcount: int
    avg_loaded_salary: float        # average fully-loaded salary per current employee
    base_non_headcount_opex: float  # monthly non-headcount costs (infra, tools, etc.)
    gross_margin_pct: float         # 0.0–1.0
    mrr_growth_rate: float          # monthly MoM growth rate, 0.0–1.0
    hiring_plan: list[HiringEntry] = field(default_factory=list)
    model_months: int = 24
    start_date: Optional[date] = None


@dataclass
class MonthResult:
    """Single month output."""
    month: int
    label: str              # e.g. "Month 1 (Apr 2025)"
    mrr: float
    gross_profit: float
    headcount: int
    headcount_cost: float   # total loaded headcount cost this month
    other_opex: float
    gross_burn: float
    net_burn: float
    cash_start: float
    cash_end: float
    runway_months: float    # projected runway from this month
    cumulative_new_arr: float   # for burn multiple


# ---------------------------------------------------------------------------
# Core calculator
# ---------------------------------------------------------------------------

class RunwayCalculator:

    def __init__(self, config: ModelConfig):
        self.cfg = config

    def run(self) -> list[MonthResult]:
        cfg = self.cfg
        results = []

        # Build headcount schedule: month -> list of new hires starting that month
        hire_by_month: dict[int, list[HiringEntry]] = {}
        for h in cfg.hiring_plan:
            hire_by_month.setdefault(h.month, []).append(h)

        # Track existing employees
        active_employees: list[dict] = []
        for _ in range(cfg.starting_headcount):
            active_employees.append({
                "monthly_loaded": cfg.avg_loaded_salary / 12 * 1.0,
                "start_month": 0,
            })

        cash = cfg.starting_cash
        mrr = cfg.starting_mrr
        cumulative_new_arr = 0.0
        starting_mrr = cfg.starting_mrr

        for m in range(1, cfg.model_months + 1):
            # Process new hires this month
            one_time_recruiting = 0.0
            if m in hire_by_month:
                for hire in hire_by_month[m]:
                    monthly_loaded = (
                        hire.annual_salary * (1 + hire.benefits_pct) / 12
                    )
                    active_employees.append({
                        "monthly_loaded": monthly_loaded,
                        "start_month": m,
                    })
                    one_time_recruiting += hire.recruiting_cost

            # Revenue this month
            mrr = mrr * (1 + cfg.mrr_growth_rate)
            gross_profit = mrr * cfg.gross_margin_pct

            # Headcount cost
            headcount_cost = sum(e["monthly_loaded"] for e in active_employees)
            headcount_cost += one_time_recruiting

            # Other opex (infra, SaaS tools, office, etc.)
            other_opex = cfg.base_non_headcount_opex

            # Burn
            gross_burn = headcount_cost + other_opex
            net_burn = gross_burn - gross_profit

            # Cash
            cash_start = cash
            cash = cash - net_burn
            cash_end = cash

            # Projected runway from this month (using current net burn rate)
            runway = cash_end / net_burn if net_burn > 0 else float("inf")

            # Cumulative new ARR (for burn multiple calc)
            new_mrr_added = mrr - starting_mrr if m == 1 else mrr - results[-1].mrr
            cumulative_new_arr += new_mrr_added * 12

            # Label
            if cfg.start_date:
                month_date = date(
                    cfg.start_date.year,
                    cfg.start_date.month,
                    1,
                ) + timedelta(days=32 * (m - 1))
                month_date = month_date.replace(day=1)
                label = f"Month {m:02d} ({month_date.strftime('%b %Y')})"
            else:
                label = f"Month {m:02d}"

            results.append(MonthResult(
                month=m,
                label=label,
                mrr=mrr,
                gross_profit=gross_profit,
                headcount=len(active_employees),
                headcount_cost=headcount_cost,
                other_opex=other_opex,
                gross_burn=gross_burn,
                net_burn=net_burn,
                cash_start=cash_start,
                cash_end=cash_end,
                runway_months=runway,
                cumulative_new_arr=cumulative_new_arr,
            ))

            # Stop if cash runs out
            if cash_end <= 0:
                break

        return results

    def cash_out_date(self, results: list[MonthResult]) -> Optional[str]:
        """Return the label of the month cash runs out, or None if model survives."""
        for r in results:
            if r.cash_end <= 0:
                return r.label
        return None

    def burn_multiple(self, results: list[MonthResult]) -> float:
        """Burn multiple = total net burn / total net new ARR over model period."""
        total_net_burn = sum(r.net_burn for r in results if r.net_burn > 0)
        first_mrr = results[0].mrr / (1 + self.cfg.mrr_growth_rate)  # starting mrr
        total_new_arr = (results[-1].mrr - first_mrr) * 12
        if total_new_arr <= 0:
            return float("inf")
        return total_net_burn / total_new_arr


# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------

def fmt_k(value: float) -> str:
    """Format as $Xk or $X.XM."""
    if abs(value) >= 1_000_000:
        return f"${value/1_000_000:.2f}M"
    if abs(value) >= 1_000:
        return f"${value/1_000:.0f}K"
    return f"${value:.0f}"


def print_summary(name: str, results: list[MonthResult], calc: RunwayCalculator) -> None:
    cash_out = calc.cash_out_date(results)
    bm = calc.burn_multiple(results)
    last = results[-1]
    first = results[0]

    print(f"\n{'='*60}")
    print(f"  SCENARIO: {name}")
    print(f"{'='*60}")
    print(f"  Months modeled:    {len(results)}")
    print(f"  Cash out:          {cash_out or 'Does not run out in model period'}")
    print(f"  Ending cash:       {fmt_k(last.cash_end)}")
    print(f"  Final runway:      {last.runway_months:.1f} months")
    print(f"  Starting MRR:      {fmt_k(first.mrr)}")
    print(f"  Ending MRR:        {fmt_k(last.mrr)}")
    print(f"  Ending headcount:  {last.headcount}")
    print(f"  Burn multiple:     {bm:.2f}x")
    print(f"  Avg net burn:      {fmt_k(sum(r.net_burn for r in results)/len(results))}/mo")

    # Decision triggers
    print(f"\n  Decision Triggers:")
    triggers = {9: "⚠️  START FUNDRAISE", 6: "🔴 COST REDUCTION PLAN", 4: "🚨 EXECUTE CUTS / BRIDGE"}
    shown = set()
    for r in results:
        for threshold, label in triggers.items():
            if r.runway_months <= threshold and threshold not in shown:
                print(f"    {r.label}: {label} (runway = {r.runway_months:.1f} mo)")
                shown.add(threshold)


def print_monthly_table(results: list[MonthResult], max_rows: int = 24) -> None:
    header = f"{'Month':<22} {'MRR':>10} {'Hdct':>6} {'Net Burn':>12} {'Cash':>12} {'Runway':>8}"
    print(f"\n{header}")
    print("-" * len(header))
    for r in results[:max_rows]:
        runway_str = f"{r.runway_months:.1f}mo" if r.runway_months != float("inf") else "∞"
        print(
            f"{r.label:<22} "
            f"{fmt_k(r.mrr):>10} "
            f"{r.headcount:>6} "
            f"{fmt_k(r.net_burn):>12} "
            f"{fmt_k(r.cash_end):>12} "
            f"{runway_str:>8}"
        )


def export_csv(scenarios: list[tuple[str, list[MonthResult]]]) -> str:
    buf = io.StringIO()
    writer = csv.writer(buf)
    writer.writerow([
        "Scenario", "Month", "Label", "MRR", "Gross Profit", "Headcount",
        "Headcount Cost", "Other Opex", "Gross Burn", "Net Burn",
        "Cash Start", "Cash End", "Runway Months"
    ])
    for name, results in scenarios:
        for r in results:
            writer.writerow([
                name, r.month, r.label,
                round(r.mrr, 2), round(r.gross_profit, 2), r.headcount,
                round(r.headcount_cost, 2), round(r.other_opex, 2),
                round(r.gross_burn, 2), round(r.net_burn, 2),
                round(r.cash_start, 2), round(r.cash_end, 2),
                round(r.runway_months, 2),
            ])
    return buf.getvalue()


# ---------------------------------------------------------------------------
# Sample data
# ---------------------------------------------------------------------------

def make_sample_configs() -> list[ModelConfig]:
    """
    Sample company: Series A SaaS startup
      - $3M cash on hand (post Series A)
      - $125K MRR (~$1.5M ARR)
      - 18 employees, $150K avg salary
      - $80K/mo non-headcount opex (infra, tools, office)
      - 72% gross margin
    """
    common_kwargs = dict(
        starting_cash=3_000_000,
        starting_mrr=125_000,
        starting_headcount=18,
        avg_loaded_salary=150_000,
        base_non_headcount_opex=80_000,
        gross_margin_pct=0.72,
        model_months=24,
        start_date=date(2025, 1, 1),
    )

    # Base: 10% MoM growth, moderate hiring
    base_hiring = [
        HiringEntry(month=2,  role="AE #1",         department="sales",       annual_salary=120_000, recruiting_cost=18_000),
        HiringEntry(month=3,  role="Senior SWE #1",  department="engineering", annual_salary=160_000, recruiting_cost=24_000),
        HiringEntry(month=5,  role="SDR #1",         department="sales",       annual_salary=80_000,  recruiting_cost=12_000),
        HiringEntry(month=6,  role="CSM #1",         department="cs",          annual_salary=90_000,  recruiting_cost=13_500),
        HiringEntry(month=8,  role="AE #2",          department="sales",       annual_salary=120_000, recruiting_cost=18_000),
        HiringEntry(month=9,  role="Senior SWE #2",  department="engineering", annual_salary=165_000, recruiting_cost=24_750),
        HiringEntry(month=12, role="Controller",     department="ga",          annual_salary=130_000, recruiting_cost=19_500),
        HiringEntry(month=14, role="AE #3",          department="sales",       annual_salary=125_000, recruiting_cost=18_750),
        HiringEntry(month=15, role="ML Engineer",    department="engineering", annual_salary=175_000, recruiting_cost=26_250),
        HiringEntry(month=18, role="AE #4",          department="sales",       annual_salary=125_000, recruiting_cost=18_750),
    ]

    # Bull: 15% MoM growth, full hiring plan
    bull_hiring = base_hiring + [
        HiringEntry(month=4,  role="Marketing Manager", department="sales",       annual_salary=110_000, recruiting_cost=16_500),
        HiringEntry(month=7,  role="Senior SWE #3",     department="engineering", annual_salary=165_000, recruiting_cost=24_750),
        HiringEntry(month=10, role="AE #5",              department="sales",       annual_salary=125_000, recruiting_cost=18_750),
        HiringEntry(month=13, role="DevOps Engineer",    department="engineering", annual_salary=150_000, recruiting_cost=22_500),
        HiringEntry(month=16, role="AE #6",              department="sales",       annual_salary=125_000, recruiting_cost=18_750),
    ]

    # Bear: 5% MoM growth, hiring freeze after month 3
    bear_hiring = [
        HiringEntry(month=2, role="AE #1",        department="sales",       annual_salary=120_000, recruiting_cost=18_000),
        HiringEntry(month=3, role="Senior SWE #1", department="engineering", annual_salary=160_000, recruiting_cost=24_000),
    ]

    return [
        ModelConfig(name="BULL  (15% MoM, full hiring)",       mrr_growth_rate=0.15, hiring_plan=bull_hiring,  **common_kwargs),
        ModelConfig(name="BASE  (10% MoM, planned hiring)",     mrr_growth_rate=0.10, hiring_plan=base_hiring,  **common_kwargs),
        ModelConfig(name="BEAR  ( 5% MoM, hiring freeze M3+)", mrr_growth_rate=0.05, hiring_plan=bear_hiring,  **common_kwargs),
        ModelConfig(name="DISTRESS (0% growth, freeze now)",    mrr_growth_rate=0.00, hiring_plan=[],           **common_kwargs),
    ]


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(description="Startup Burn Rate & Runway Calculator")
    parser.add_argument("--csv", action="store_true", help="Export full monthly data as CSV to stdout")
    parser.add_argument("--scenario", choices=["bull", "base", "bear", "distress", "all"], default="all")
    args = parser.parse_args()

    configs = make_sample_configs()
    if args.scenario != "all":
        configs = [c for c in configs if args.scenario.upper() in c.name.upper()]

    all_results: list[tuple[str, list[MonthResult]]] = []

    print("\n" + "="*60)
    print("  BURN RATE & RUNWAY CALCULATOR")
    print("  Sample Company: Series A SaaS Startup")
    print("  Starting cash: $3M | Starting MRR: $125K | 18 employees")
    print("="*60)

    for cfg in configs:
        calc = RunwayCalculator(cfg)
        results = calc.run()
        all_results.append((cfg.name, results))
        print_summary(cfg.name, results, calc)
        print_monthly_table(results)

    # Comparison summary
    print("\n" + "="*60)
    print("  SCENARIO COMPARISON")
    print("="*60)
    print(f"  {'Scenario':<40} {'Runway':>8} {'Cash Out':<30} {'Burn Mult':>10}")
    print("  " + "-"*88)
    for cfg, (name, results) in zip(configs, all_results):
        calc = RunwayCalculator(cfg)
        cash_out = calc.cash_out_date(results) or "Survives model period"
        bm = calc.burn_multiple(results)
        final_runway = results[-1].runway_months
        runway_str = f"{final_runway:.1f}mo" if final_runway != float("inf") else "∞"
        bm_str = f"{bm:.2f}x" if bm != float("inf") else "∞"
        print(f"  {name:<40} {runway_str:>8} {cash_out:<30} {bm_str:>10}")

    print("\n  Decision Trigger Reference:")
    print("    9 months runway → Start fundraise process")
    print("    6 months runway → Begin cost reduction planning")
    print("    4 months runway → Execute cuts; explore bridge financing")
    print("    3 months runway → Emergency plan only")

    if args.csv:
        print("\n\n--- CSV EXPORT ---\n")
        sys.stdout.write(export_csv(all_results))


if __name__ == "__main__":
    main()
