# -*- coding: utf-8 -*-
"""
model_agedcare.py — C10 AGED CARE FINANCIAL MODEL.

OWNER: 01_System/model_agedcare.py (this file is the source, not a projection)
REVIEW: regenerate every downstream artefact whenever an input here changes.

Every input carries the SRC-### or ASM-### it comes from. Nothing is hard-coded without a
reference. Serves REQ-SYS-05, REQ-SYS-07, REQ-SYS-10, REQ-SYS-11, REQ-AC-01..REQ-AC-04.

THE STRUCTURAL POINT THIS MODEL EXISTS TO MAKE
----------------------------------------------
In the NDIS, revenue per participant is limited by how many HOURS you can sell: the price is a
hard ceiling but the volume is open. In aged care under Support at Home, revenue per client is
limited by the participant's FIXED CLASSIFICATION BUDGET: the price is not capped, but the pot
is. An independent verifier established that even the top classification buys about thirteen
hours a week and most buy under five (V4). So the two businesses are NOT the same business with
different prices — they have different binding constraints, and comparing them per hour is
misleading. This model therefore compares them PER CLIENT PER MONTH, which is the unit both
businesses actually acquire and serve.
"""
import model_params as NDIS

# =========================================================================
# BLOCK 1 — SOURCED RATE INPUTS
# =========================================================================
INPUTS = {
    # key: (value, unit, source_id, confidence)
    "price_personal_care":    (103.11, "AUD/hr",  "SRC-060", "Medium"),
    "price_domestic_assist":  (111.87, "AUD/hr",  "SRC-060", "Medium"),
    "price_nursing":          (184.55, "AUD/hr",  "SRC-060", "Medium"),
    "wage_homecare_l2_casual":(43.03,  "AUD/hr",  "SRC-062", "Medium"),
    "wage_homecare_l3_casual":(45.29,  "AUD/hr",  "SRC-062", "Medium"),
    "care_mgmt_share":        (0.10,   "rate",    "SRC-063", "High"),
    "budget_level_1":         (10731,  "AUD/yr",  "SRC-064", "Medium"),
    "budget_level_8":         (78106,  "AUD/yr",  "SRC-064", "Medium"),
}

# =========================================================================
# BLOCK 2 — ASSUMED DRIVERS
# =========================================================================
DRIVERS = {
    "avg_client_budget":            (30000, "AUD/yr", "ASM-020", "Low"),
    "care_mgmt_hours_per_client_mo":(1.5,   "hr",     "ASM-021", "Low"),
    "admin_hours_per_billable_hour":(0.25,  "hr",     "ASM-008", "Low"),
    "payment_lag_days":             (7,     "days",   "ASM-030", "Low"),
    "participant_contribution_share":(0.15, "rate",   "ASM-022", "Low"),
    "contribution_bad_debt":        (0.02,  "rate",   "ASM-023", "Low"),
    "clients_target":               (12,    "count",  "ASM-024", "Low"),
    # DEF-024. The care-management and administration rate was an unnamed copy of the NDIS
    # coordinator wage buried inside client_economics(), so the workbook projected a different
    # figure from the model and neither was wrong on its face. It is now an input with a name,
    # an assumption and a reason.
    "wage_coordinator_base":        (50.61,  "AUD/hr", "ASM-031", "Low"),
}

# NDIS comparison basis: one participant using this many hours a week.
NDIS_HOURS_PER_CLIENT_WEEK = (15, "hr", "ASM-025", "Low")

def v(d, k):
    return d[k][0]


# =========================================================================
# BLOCK 3 — COMPUTATION
# =========================================================================
def loaded_wage(level="l2"):
    """Same Victorian on-cost stack as the NDIS model — superannuation, WorkCover and the
    portable long service leave levy — applied to the SCHADS Schedule F Home Care rate.
    Reusing NDIS.oncost_multiplier() rather than restating it is deliberate: one definition,
    one place to correct it."""
    base = INPUTS["wage_homecare_l2_casual"][0] if level == "l2" else INPUTS["wage_homecare_l3_casual"][0]
    return base * NDIS.oncost_multiplier()

def coordinator_loaded_wage():
    """ASM-031. Care management and administration in the aged care entity are costed at the
    Social and Community Services Level 3 rate rather than the SCHADS Schedule F Home Care Level 3
    rate, DELIBERATELY and conservatively. IHACPA prices care management between $116.22 and
    $126.83 an hour, which implies a role materially more senior than a home carer. Using the
    Schedule F rate instead would RAISE the modelled contribution by about $43 a client a month,
    so the higher rate is the choice that makes the aged care case harder to prove, not easier.
    A change that flatters the recommendation deserves more scrutiny than one that does not."""
    return v(DRIVERS, "wage_coordinator_base") * NDIS.oncost_multiplier()


def gross_margin_per_hour(level="l2"):
    """The headline number, and the misleading one. See the module docstring."""
    return INPUTS["price_personal_care"][0] - loaded_wage(level)

def hours_per_client_month(budget=None):
    """What the participant's fixed budget actually buys, after the mandatory care-management
    deduction. THIS is the binding constraint in aged care."""
    b = budget if budget is not None else v(DRIVERS, "avg_client_budget")
    service_pool = b * (1 - INPUTS["care_mgmt_share"][0]) / 12.0
    return service_pool / INPUTS["price_personal_care"][0]

def hours_per_client_week(budget=None):
    return hours_per_client_month(budget) * 12.0 / 52.0

def client_economics(budget=None, admin_paid=True, level="l2"):
    """Contribution per CLIENT per MONTH — the unit both businesses actually acquire."""
    b = budget if budget is not None else v(DRIVERS, "avg_client_budget")
    monthly_budget = b / 12.0
    care_mgmt_revenue = monthly_budget * INPUTS["care_mgmt_share"][0]
    service_revenue = monthly_budget - care_mgmt_revenue
    hours = service_revenue / INPUTS["price_personal_care"][0]
    direct_labour = hours * loaded_wage(level)
    service_contribution = service_revenue - direct_labour

    cm_hours = v(DRIVERS, "care_mgmt_hours_per_client_mo")
    admin_hours = hours * v(DRIVERS, "admin_hours_per_billable_hour")
    coord_rate = coordinator_loaded_wage()
    care_mgmt_cost = (cm_hours * coord_rate) if admin_paid else 0.0
    admin_cost = (admin_hours * coord_rate) if admin_paid else 0.0

    bad_debt = (monthly_budget * v(DRIVERS, "participant_contribution_share")
                * v(DRIVERS, "contribution_bad_debt"))

    return {
        "budget_per_year": round(b, 2),
        "revenue_per_month": round(monthly_budget, 2),
        "care_management_revenue": round(care_mgmt_revenue, 2),
        "service_revenue": round(service_revenue, 2),
        "billable_hours_per_month": round(hours, 2),
        "billable_hours_per_week": round(hours * 12 / 52.0, 2),
        "direct_labour": round(direct_labour, 2),
        "service_contribution": round(service_contribution, 2),
        "care_management_cost": round(care_mgmt_cost, 2),
        "service_admin_cost": round(admin_cost, 2),
        "bad_debt_allowance": round(bad_debt, 2),
        "contribution_per_client_month": round(
            service_contribution + care_mgmt_revenue - care_mgmt_cost - admin_cost - bad_debt, 2),
    }

def ndis_client_economics(admin_paid=True):
    """The same unit, computed for the NDIS business, so the two are comparable."""
    hours = NDIS_HOURS_PER_CLIENT_WEEK[0] * 52.0 / 12.0
    price = NDIS.INPUTS["price_selfcare_wd"][0]
    revenue = hours * price
    direct_labour = hours * NDIS.loaded_wage("l2")
    admin_hours = hours * NDIS.v(NDIS.DRIVERS, "admin_hours_per_billable_hour")
    admin_cost = (admin_hours * NDIS.loaded_wage("l3")) if admin_paid else 0.0
    return {
        "revenue_per_month": round(revenue, 2),
        "billable_hours_per_month": round(hours, 2),
        "billable_hours_per_week": float(NDIS_HOURS_PER_CLIENT_WEEK[0]),
        "direct_labour": round(direct_labour, 2),
        "care_management_revenue": 0.0,
        "service_admin_cost": round(admin_cost, 2),
        "contribution_per_client_month": round(revenue - direct_labour - admin_cost, 2),
    }

def budget_sensitivity():
    """Contribution per client across the classification range.

    DEF-032. This used to start at $12,000, which is not a classification — it was a round number.
    Independent verification V6 pointed out that the registered bottom ONGOING classification is
    $10,731 (SRC-064), which buys 1.80 service hours a week, and that the delivered study therefore
    never showed its own weakest row. The range now runs between the two real endpoints held in
    INPUTS, so it cannot drift from the source again."""
    lo = INPUTS["budget_level_1"][0]
    hi = INPUTS["budget_level_8"][0]
    out = []
    for b in (lo, 20000, 30000, 45000, 60000, hi):
        e = client_economics(budget=b)
        out.append((b, e["billable_hours_per_week"], e["revenue_per_month"],
                    e["contribution_per_client_month"]))
    return out


# =========================================================================
# BLOCK 4 — ONE-OFF AND RECURRING COSTS
# =========================================================================
# (label, low, base, high, source_id, confidence)
ONE_OFF_COSTS = [
 ("ASIC Pty Ltd company registration",              636,  636,  636,  "SRC-025", "Medium"),
 ("ASIC business name, three years",                108,  108,  108,  "SRC-027", "Medium"),
 ("ABN, TFN, GST and PAYG registration",              0,    0,    0,  "SRC-028", "High"),
 ("Worker screening, per person",                 139.20,139.20,139.20,"SRC-031","High"),
 ("First aid plus CPR, per person",                 290,  290,  290,  "SRC-041", "Medium"),
 ("Website and brand identity",                    2500, 2500, 2500,  "ASM-006", "Low"),
 ("Policy and procedure manual, aged care standards", 4997, 4997, 4997, "SRC-066", "High"),
 ("Aged Care Quality and Safety Commission registration fee",
                                                    600, 1200, 3000,  "SRC-065", "Low"),
]
RECURRING_MONTHLY = [
 ("Care management and rostering software",          45,  120,  300,  "ASM-027", "Low"),
 ("Accounting and payroll",                          78,   78,  143,  "SRC-038", "High"),
 ("Bookkeeping",                                    200,  300,  500,  "ASM-005", "Low"),
 ("General operating overhead",                     150,  200,  350,  "ASM-014", "Low"),
]
RECURRING_ANNUAL = [
 ("ASIC company annual review",                     342,  342,  342,  "SRC-026", "Medium"),
 ("Insurance: public liability, professional indemnity, management liability",
                                                   1800, 3200, 5000,  "SRC-067", "Low"),
]

WORKERS_AT_START = 3

def band(rows, idx):
    return sum(r[idx] for r in rows)

def one_off_total(workers=WORKERS_AT_START):
    people = workers + 1
    out = []
    for i in (1, 2, 3):
        t = 0.0
        for label, lo, ba, hi, src, conf in ONE_OFF_COSTS:
            val = (lo, ba, hi)[i - 1]
            if "per person" in label:
                val *= people
            t += val
        out.append(round(t, 2))
    return tuple(out)

def monthly_fixed():
    ann = [band(RECURRING_ANNUAL, i) / 12.0 for i in (1, 2, 3)]
    mon = [band(RECURRING_MONTHLY, i) for i in (1, 2, 3)]
    return tuple(round(m + a, 2) for m, a in zip(mon, ann))

def breakeven_clients(admin_paid=True, which=2):
    """REQ-SYS-10 for aged care: the unit is CLIENTS, not hours, because the budget caps hours."""
    return monthly_fixed()[which - 1] / client_economics(admin_paid=admin_paid)["contribution_per_client_month"]

def working_capital(clients=None):
    """REQ-SYS-11. Payment is in arrears through Services Australia at roughly a seven-day lag,
    against a wage cycle. Materially lighter than the NDIS plan-manager lag."""
    n = clients if clients is not None else v(DRIVERS, "clients_target")
    e = client_economics()
    wages = n * (e["direct_labour"] + e["care_management_cost"] + e["service_admin_cost"])
    return wages * (v(DRIVERS, "payment_lag_days") / 30.0)

def runway_downside(months=6, owner_draw=0.0, which=2, workers=None):
    w = WORKERS_AT_START if workers is None else workers
    return round(one_off_total(workers=w)[which - 1] + (monthly_fixed()[which - 1] + owner_draw) * months, 2)


# =========================================================================
# BLOCK 5 — TWO ENTITIES vs ONE (Zaid chose two; this prices that choice)
# =========================================================================
def structure_comparison():
    """Zaid chose two separate entities. This quantifies what the separation costs, so the
    choice is made with the number visible rather than on principle alone."""
    ndis_fixed = NDIS.monthly_fixed()[1]
    ac_fixed = monthly_fixed()[1]
    ndis_oneoff = NDIS.one_off_total()[1]
    ac_oneoff = one_off_total()[1]

    # Shared-entity saving: one company, one insurance policy, one bookkeeper, one accounting
    # subscription, one set of company fees. Software and worker costs do not halve.
    shared_monthly_saving = (78 + 300 + 200) + (342 / 12.0)          # accounting, bookkeeping, overhead, ASIC
    shared_oneoff_saving = 636 + 108                                  # one company, one business name
    combined_two = ndis_fixed + ac_fixed
    combined_one = combined_two - shared_monthly_saving
    return {
        "ndis_fixed_month": round(ndis_fixed, 2),
        "agedcare_fixed_month": round(ac_fixed, 2),
        "two_entities_fixed_month": round(combined_two, 2),
        "one_entity_fixed_month": round(combined_one, 2),
        "monthly_cost_of_separation": round(shared_monthly_saving, 2),
        "annual_cost_of_separation": round(shared_monthly_saving * 12, 2),
        "two_entities_oneoff": round(ndis_oneoff + ac_oneoff, 2),
        "one_entity_oneoff": round(ndis_oneoff + ac_oneoff - shared_oneoff_saving, 2),
        "oneoff_cost_of_separation": round(shared_oneoff_saving, 2),
        "three_year_cost_of_separation": round(shared_monthly_saving * 36 + shared_oneoff_saving, 2),
    }


# =========================================================================
# BLOCK 6 — HEADLINE RESULT SET
# =========================================================================
def results():
    ac_paid = client_economics(admin_paid=True)
    ac_owner = client_economics(admin_paid=False)
    nd_paid = ndis_client_economics(admin_paid=True)
    nd_owner = ndis_client_economics(admin_paid=False)
    return {
        "loaded_wage_l2": round(loaded_wage("l2"), 2),
        "loaded_wage_l3": round(loaded_wage("l3"), 2),
        "gross_margin_hr": round(gross_margin_per_hour("l2"), 2),
        "gross_margin_pct": round(gross_margin_per_hour("l2") / INPUTS["price_personal_care"][0] * 100, 1),
        "ndis_gross_margin_hr": round(NDIS.gross_margin_per_hour(), 2),
        "hours_per_client_week": round(hours_per_client_week(), 2),
        "hours_top_classification_week": round(hours_per_client_week(INPUTS["budget_level_8"][0]), 2),
        "hours_bottom_classification_week": round(hours_per_client_week(INPUTS["budget_level_1"][0]), 2),
        "ac_client_paid_admin": ac_paid,
        "ac_client_owner_admin": ac_owner,
        "ndis_client_paid_admin": nd_paid,
        "ndis_client_owner_admin": nd_owner,
        "contribution_ratio_paid_admin": round(
            ac_paid["contribution_per_client_month"] / nd_paid["contribution_per_client_month"], 2),
        "one_off": one_off_total(),
        "monthly_fixed": monthly_fixed(),
        "breakeven_clients_paid_admin": round(breakeven_clients(True), 1),
        "breakeven_clients_owner_admin": round(breakeven_clients(False), 1),
        "working_capital": round(working_capital(), 2),
        "runway_6mo_no_draw": runway_downside(6, 0.0),
        "runway_6mo_draw6k": runway_downside(6, 6000.0),
        "budget_sensitivity": budget_sensitivity(),
        "structure": structure_comparison(),
    }


if __name__ == "__main__":
    R = results()
    for k, val in R.items():
        print(k, "=", val)
