from fractions import Fraction as F
from decimal import Decimal, localcontext, ROUND_HALF_UP
from collections import defaultdict
from copy import deepcopy
import json

def row(customer, item, amount, interval='month', n=1, status='active',
        currency='usd', quantity=1, percent=0, usage='licensed', **extra):
    # Synthetic fixture adapter only: exclusive tax, per_unit, one percent coupon.
    return dict(customer=customer, item=item, amount=amount, interval=interval,
                n=n, status=status, currency=currency, quantity=quantity,
                percent=percent, usage=usage, **extra)

def normalize(raw):
    if raw['interval'] not in {'month', 'year'}:
        raise ValueError('unsupported billing interval')
    if raw.get('billing_scheme', 'per_unit') != 'per_unit':
        raise ValueError('unsupported price: use audited normalized rows')
    amount = F(raw['amount'])
    quantity = raw['quantity']
    n = raw['n']
    percent = F(raw['percent'])
    if amount < 0 or not isinstance(quantity, int) or quantity < 0:
        raise ValueError('invalid amount or quantity')
    if not isinstance(n, int) or n < 1 or not 0 <= percent <= 100:
        raise ValueError('invalid interval_count or discount')
    months = n * (12 if raw['interval'] == 'year' else 1)
    net = amount * quantity * (1 - percent / 100)
    eligible = raw['status'] in {'active', 'past_due'} and raw['usage'] == 'licensed'
    mrr = net / months if eligible else F(0)
    return dict(customer=raw['customer'], item=raw['item'],
                currency=raw['currency'], mrr_minor=mrr)

def snapshot(rows):
    totals = defaultdict(F)
    seen = set()
    for raw in rows:
        key = raw['item']
        if key in seen:
            raise ValueError('duplicate item: ' + key)
        seen.add(key)
        r = normalize(raw)
        totals[(r['currency'], r['customer'])] += r['mrr_minor']
    return dict(totals)

def cohort(begin, end, currency):
    b = {c: v for (cur, c), v in begin.items() if cur == currency and v > 0}
    e = {c: v for (cur, c), v in end.items() if cur == currency and v > 0}
    starting = sum(b.values(), F(0))
    lost = sorted(c for c in b if e.get(c, 0) == 0)
    retained = sum((e.get(c, F(0)) for c in b), F(0))
    gross_retained = sum((min(v, e.get(c, F(0))) for c, v in b.items()), F(0))
    outside_opening = sum((v for c, v in e.items() if c not in b), F(0))
    return dict(begin_mrr=starting, end_mrr=sum(e.values(), F(0)),
                begin_customers=len(b), end_customers=len(e), lost=lost,
                logo_churn=F(len(lost), len(b)) if b else None,
                grr=gross_retained / starting if starting else None,
                nrr=retained / starting if starting else None,
                outside_opening_mrr=outside_opening)

def exact_decimal(x, digits=2):
    if x is None:
        return 'N/A'
    with localcontext() as ctx:
        ctx.prec = 40
        v = Decimal(x.numerator) / Decimal(x.denominator)
        return str(v.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP))

begin = [
    row('A', 'a', 120000, 'year'),
    row('B', 'b1', 6000), row('B', 'b2', 4000),
    row('C', 'c', 10000),
    row('D', 'd', 10000, percent=20),
    row('E', 'e', 12000, status='past_due', cancel_at_period_end=True),
    row('H', 'h', 10000, status='trialing'),
    row('I', 'i', 90000, usage='metered'),
    row('J', 'j', 0), row('K', 'k', 10000, status='unpaid'),
    row('L', 'l', 10000, percent=100),
    row('M', 'm', 12000, currency='jpy')]
end = deepcopy(begin)
for r in end:
    if r['item'] in {'b2', 'c'}:
        r['status'] = 'canceled'
    if r['item'] == 'd':
        r['percent'] = 0
end += [row('F', 'f', 5000), row('G', 'g', 3000)]
b, e = snapshot(begin), snapshot(end)
r = cohort(b, e, 'usd')
checks = 0

def check(ok):
    global checks
    if not ok:
        raise AssertionError('check ' + str(checks + 1))
    checks += 1

check(r['begin_mrr'] == 50000)
check(r['end_mrr'] == 46000)
check((r['begin_customers'], r['end_customers']) == (5, 6))
check(r['lost'] == ['C'])
check(r['logo_churn'] == F(1, 5))
check(r['grr'] == F(72, 100))
check(r['nrr'] == F(76, 100))
check(r['outside_opening_mrr'] == 8000)
check(cohort(b, e, 'jpy')['end_mrr'] == 12000)
check(normalize(row('X', 'x', 120000, 'year'))['mrr_minor'] == 10000)
check(normalize(row('X', 'x', 30000, n=3))['mrr_minor'] == 10000)
check(normalize(row('X', 'x', 1000, quantity=3))['mrr_minor'] == 3000)
check(normalize(row('X', 'x', 10000, percent=100))['mrr_minor'] == 0)
check(normalize(row('X', 'x', 10000, status='past_due'))['mrr_minor'] == 10000)
for status in ['unpaid', 'canceled', 'paused', 'trialing', 'incomplete', 'incomplete_expired']:
    check(normalize(row('X', 'x', 10000, status=status))['mrr_minor'] == 0)
check(normalize(row('X', 'x', 10000, usage='metered'))['mrr_minor'] == 0)
check(normalize(row('X', 'x', 10000, cancel_at_period_end=True))['mrr_minor'] == 10000)
check(normalize(row('X', 'x', 10000, refund_minor=5000))['mrr_minor'] == 10000)
check(normalize(row('X', 'x', 10000, pause_collection=True))['mrr_minor'] == 10000)
check(normalize(row('X', 'x', 100, 'year'))['mrr_minor'] == F(25, 3))
check(F(1, 5 + 1) != r['logo_churn'])
check(cohort({}, {}, 'usd')['logo_churn'] is None)
check(cohort({}, {}, 'usd')['nrr'] is None)
for bad in [row('X', 'x', 1000, interval='week'),
            row('X', 'x', 1000, billing_scheme='tiered'),
            row('X', 'x', 1000, quantity=-1),
            row('X', 'x', 1000, n=0)]:
    try:
        snapshot([bad])
    except ValueError:
        checks += 1
    else:
        raise AssertionError('unsupported input accepted')
try:
    snapshot([begin[0], begin[0]])
except ValueError:
    checks += 1
else:
    raise AssertionError('duplicate item accepted')
# Full-period movements are provided independently of the two snapshots.
movement = dict(new=5000, reactivation=3000, expansion=2000,
                contraction=4000, churn=10000, fx=0)
bridge_end = (r['begin_mrr'] + movement['new'] + movement['reactivation']
              + movement['expansion'] - movement['contraction'] - movement['churn']
              + movement['fx'])
check(bridge_end == r['end_mrr'])
check(movement['churn'] + movement['contraction'] == 14000)
# September is exactly 30 days. F is first-time, G is reactivated; no repeat churn.
stripe_30d_example = F(1, 5 + 1)
check(stripe_30d_example == F(1, 6))
report = {
    'fixture_only': True, 'checks_passed': checks,
    'usd_begin_mrr': exact_decimal(r['begin_mrr'] / 100),
    'usd_end_mrr': exact_decimal(r['end_mrr'] / 100),
    'usd_logo_churn_percent': exact_decimal(r['logo_churn'] * 100),
    'usd_grr_percent': exact_decimal(r['grr'] * 100),
    'usd_nrr_percent': exact_decimal(r['nrr'] * 100),
    'stripe_30d_formula_example_percent': exact_decimal(stripe_30d_example * 100),
    'jpy_end_mrr': exact_decimal(cohort(b, e, 'jpy')['end_mrr'], 0),
    'bridge_residual_minor': exact_decimal(r['end_mrr'] - bridge_end),
    'lost_customers': r['lost']}
print(json.dumps(report, ensure_ascii=False, indent=2))
