#!/usr/bin/env python3
"""Illustration locale, sans appel eBay ni remboursement. Python standard.
Montants USD en cents ; paliers fictifs ; taxes, transport, commissions exclus.
Les arrondis non entiers et les différences négatives exigent une revue.
"""
from itertools import permutations
import json

TIERS = ((2, 10), (3, 20), (4, 25))
CAP = 10000

def check_lines(lines):
    if not isinstance(lines, (list, tuple)):
        raise ValueError('lignes')
    seen = set()
    for row in lines:
        if not isinstance(row, dict) or set(row) != {'id', 'cents', 'qty', 'eligible'}:
            raise ValueError('contrat ligne')
        if not isinstance(row['id'], str) or not row['id'] or row['id'] in seen:
            raise ValueError('identifiant ligne')
        seen.add(row['id'])
        if type(row['cents']) is not int or row['cents'] <= 0:
            raise ValueError('prix')
        if type(row['qty']) is not int or row['qty'] <= 0:
            raise ValueError('quantité')
        if type(row['eligible']) is not bool:
            raise ValueError('éligibilité inconnue')

def quote(lines):
    check_lines(lines)
    count = sum(r['qty'] for r in lines if r['eligible'])
    gross = sum(r['qty'] * r['cents'] for r in lines)
    base = sum(r['qty'] * r['cents'] for r in lines if r['eligible'])
    pct = max((pct for minimum, pct in TIERS if count >= minimum), default=0)
    raw = base * pct
    # Aucun arrondi prétendument équivalent à celui d'eBay.
    if raw < CAP * 100 and raw % 100:
        raise ValueError('arrondi à rapprocher avec eBay')
    discount = min(raw // 100, CAP)
    return {'eligible_qty': count, 'gross': gross, 'discount': discount, 'net': gross-discount}

def after_return(lines, returned):
    check_lines(lines)
    if not isinstance(returned, dict):
        raise ValueError('retour')
    ids = {r['id'] for r in lines}
    if set(returned) - ids:
        raise ValueError('ligne inconnue')
    kept = []
    for row in lines:
        qty = returned.get(row['id'], 0)
        if type(qty) is not int or not 0 <= qty <= row['qty']:
            raise ValueError('quantité retournée')
        if row['qty'] > qty:
            kept.append({**row, 'qty': row['qty']-qty})
    original, rest = quote(lines), quote(kept)
    delta = original['net'] - rest['net']
    if delta < 0:
        # Ne pas inventer un débit acheteur, ni masquer par max(0, delta).
        return {'status':'review', 'difference':delta, 'kept':rest['net'], 'refund':None}
    return {'status':'illustration', 'difference':delta, 'kept':rest['net'], 'refund':delta}

def row(id, cents=2000, qty=1, eligible=True):
    return dict(id=id, cents=cents, qty=qty, eligible=eligible)

def run():
    checks = 0
    def eq(actual, expected):
        nonlocal checks
        assert actual == expected, (actual, expected)
        checks += 1
    def refuses(fn):
        nonlocal checks
        try: fn()
        except ValueError: checks += 1
        else: raise AssertionError('refus attendu')
    a = [row('A'), row('B'), row('C')]
    eq(quote(a), dict(eligible_qty=3, gross=6000, discount=1200, net=4800))
    eq(after_return(a, {'C':1})['refund'], 1200)
    eq(after_return(a, {'B':1,'C':1})['refund'], 2800)
    eq(after_return(a, dict(A=1,B=1,C=1))['refund'], 4800)
    eq(after_return(a, {})['refund'], 0)
    # Multiples de la même annonce : même nombre d'unités dans cet exercice.
    eq(quote([row('A',qty=3)]), quote(a))
    eq(after_return([row('A',qty=3)], {'A':1})['refund'], 1200)
    for perm in permutations(a):
        eq(after_return(perm, {'C':1})['refund'],1200)
    cap = [row('L',12000,5)]
    eq(quote(cap)['net'],50000)
    eq(after_return(cap, {'L':1})['kept'],38000)
    eq(after_return(cap, {'L':1})['refund'],12000)
    for cents,discount in [(9999,9999),(10000,10000),(10001,10000)]:
        eq(quote([row('X',cents,4)])['discount'],discount)
    mixed=[row('A'),row('B'),row('X',6000,eligible=False)]
    eq(quote(mixed),dict(eligible_qty=2,gross=10000,discount=400,net=9600))
    for perm in permutations(mixed): eq(quote(perm)['net'],9600)
    eq(after_return(mixed, {'X':1})['refund'],6000)
    eq(quote([row('X',6000,eligible=False)])['discount'],0)
    eq(quote([])['net'],0)
    # Publication d'une nouvelle annonce, avant/après décision d'exclusion.
    eq(quote([row('A'),row('B'),row('NEW')])['net'],4800)
    eq(quote([row('A'),row('B'),row('NEW',eligible=False)])['net'],5600)
    negative=[row('BIG',10000),row('small1',100),row('small2',100)]
    eq(after_return(negative,dict(small1=1,small2=1)),
       dict(status='review',difference=-1840,kept=10000,refund=None))
    for field, value in [('qty',True),('qty',0),('qty',-1),('qty','2'),('cents',True),
                         ('cents',0),('cents',1.5),('eligible',None),('eligible',1),('id','')]:
        refuses(lambda f=field,v=value:quote([{**row('X'),f:v}]))
    refuses(lambda:quote([row('A'),row('A')]))
    refuses(lambda:quote([{**row('X'),'currency':'USD'}]))
    refuses(lambda:quote([row('X',1001,2)]))
    for returned in [{'Z':1},{'A':2},{'A':-1},{'A':True},{'A':'1'}]:
        refuses(lambda r=returned:after_return(a,r))
    eq(1600-1200,400)   # ancienne ligne nette : surremboursement de 4 dollars
    eq(3200-2800,400)   # deux anciennes lignes : même défaut
    eq(10000-9600,400)  # perte de la remise si éligibilité oubliée
    eq(45000 != quote(cap)['net'],True)  # plafond oublié : 450 au lieu de 500
    eq(8000 != quote(mixed)['net'],True) # remise sur tout : 80 au lieu de 96
    eq(18*7,126)
    return dict(status='PASS',checks=checks,unit='USD cents',network=False,
                examples={'initial':4800,'keep_two':3600,'refund_one':1200,
                          'keep_one':2000,'refund_two':2800,'cap_initial':50000,
                          'cap_keep_four':38000,'cap_refund':12000,'mixed':9600,
                          'unresolved_negative':-1840},minutes_hypothesis=126)

if __name__ == '__main__':
    print(json.dumps(run(),ensure_ascii=False,indent=2))
