"""Reference solution for SaaS retention analysis project: explain growth that hides a shrinking cohort.
Original synthetic teaching data only. Python 3.10+, standard library.
Run python solve.py [path-to-project-inputs]. No network or credentials.
"""
from pathlib import Path
from datetime import datetime, date
import csv, json, math, statistics, sys

def read(root, name):
    with (Path(root)/name).open(newline='',encoding='utf-8') as f:
        reader=csv.DictReader(f)
        if not reader.fieldnames or len(reader.fieldnames)!=len(set(reader.fieldnames)):
            raise ValueError(f'{name}: missing or duplicate headers')
        rows=list(reader)
        if any(None in row or any(v is None for v in row.values()) for row in rows):
            raise ValueError(f'{name}: inconsistent row length')
        return rows

def rules(root): return json.loads((Path(root)/'rules.json').read_text())

def number(value):
    x=float(value)
    if not math.isfinite(x): raise ValueError('Non-finite input')
    return x

def nonnegative(value):
    x=number(value)
    if x<0:raise ValueError('Negative quantity or amount')
    return x

def flag(value):
    if value not in ('true','false'):raise ValueError('Boolean must be true or false')
    return value=='true'

def timestamp(value):
    d=datetime.fromisoformat(value.replace('Z','+00:00'))
    if d.tzinfo is None:raise ValueError('Timestamp requires an explicit time zone')
    return d

def unique(rows, keys, ignore=()):
    by={};duplicates=0
    for row in rows:
        key=tuple(row[k] for k in keys)
        if any(not k for k in key):raise ValueError('Missing record identity')
        if key in by:
            prior={k:v for k,v in by[key].items() if k not in ignore}
            new={k:v for k,v in row.items() if k not in ignore}
            if prior!=new:raise ValueError(f'Conflicting records at {key}')
            duplicates+=1
        else:by[key]=row
    return list(by.values()),duplicates

def rounded(value):
    if isinstance(value,float): return round(value,8)
    if isinstance(value,dict):return {k:rounded(v) for k,v in value.items()}
    if isinstance(value,list):return [rounded(v) for v in value]
    return value

def month_index(s):
    d=datetime.strptime(s,'%Y-%m')
    if d.strftime('%Y-%m')!=s:raise ValueError('Use YYYY-MM')
    return d.year*12+d.month

def saas_retention(root):
    r=rules(root);raw=read(root,'subscriptions.csv');subs,retries=unique(raw,['month','subscription_id'])
    accounts,_=unique(read(root,'accounts.csv'),['account_id']);known={a['account_id']:flag(a['had_positive_usd_before_january']) for a in accounts}
    months={r['opening']:{},r['closing']:{}}
    excluded=0
    for x in subs:
        if x['account_id'] not in known:raise ValueError('Unmapped account')
        if x['month'] not in months:raise ValueError('Unexpected month')
        if x['currency']!=r['currency'] or x['status'] not in r['included_statuses']:
            excluded+=1;continue
        if x['billing_interval'] not in ('month','year'):raise ValueError('Unknown interval')
        mrr=nonnegative(x['recurring_amount'])/(r['annual_divisor'] if x['billing_interval']=='year' else 1)
        target=months[x['month']];target[x['account_id']]=target.get(x['account_id'],0)+mrr
    a,b=months[r['opening']],months[r['closing']];cohort={k for k,v in a.items() if v>0}
    opening=sum(a[k] for k in cohort)
    if not opening:raise ValueError('Empty opening cohort')
    churn=sum(a[k] for k in cohort if b.get(k,0)==0)
    contraction=sum(a[k]-b[k] for k in cohort if 0<b.get(k,0)<a[k])
    expansion=sum(b[k]-a[k] for k in cohort if b.get(k,0)>a[k])
    new=sum(v for k,v in b.items() if k not in cohort and not known[k])
    reactivation=sum(v for k,v in b.items() if k not in cohort and known[k])
    ending_cohort=sum(b.get(k,0) for k in cohort);ending_total=sum(b.values())
    if abs(opening-churn-contraction+expansion+new+reactivation-ending_total)>1e-8:raise ValueError('MRR bridge does not reconcile')
    return {'raw_rows':len(raw),'retry_rows_removed':retries,'rows_outside_included_mrr':excluded,'opening_accounts':len(cohort),'retained_accounts':sum(b.get(k,0)>0 for k in cohort),'opening_mrr':opening,'churn_mrr':churn,'contraction_mrr':contraction,'expansion_mrr':expansion,'new_mrr':new,'reactivation_mrr':reactivation,'ending_cohort_mrr':ending_cohort,'ending_total_mrr':ending_total,'gross_revenue_retention':(opening-churn-contraction)/opening,'net_revenue_retention':ending_cohort/opening,'logo_retention':sum(b.get(k,0)>0 for k in cohort)/len(cohort)}

def main():
    directory=Path(sys.argv[1]) if len(sys.argv)>1 else Path(__file__).resolve().parent
    try:
        result=saas_retention(directory)
        print(json.dumps(rounded(result),indent=2,allow_nan=False))
    except (OSError,ValueError,KeyError,TypeError,ZeroDivisionError) as exc:
        print(f"Input could not be analyzed: {exc}",file=sys.stderr);return 2
    return 0
if __name__=='__main__':raise SystemExit(main())
