"""Scénario fictif : réconcilier des suivis sans créer une expédition.
Aucun payload Amazon, réseau, transport ou message client réel.
Les générations et révisions sont des compteurs locaux, pas des versions Amazon.
"""
from copy import deepcopy
import json

P1 = ('VENDEUR-A', 'COM-42', 'EXP-7', 'COL-1')
P2 = ('VENDEUR-A', 'COM-42', 'EXP-7', 'COL-2')


class Projection:
    def __init__(self):
        self.packages = {P1: {'pair': ('C1', '001'), 'revision': 1, 'generation': 0, 'dirty': False},
                         P2: {'pair': ('C1', '002'), 'revision': 1, 'generation': 0, 'dirty': False}}
        self.seen = {}
        self.history = []
        self.outbox = {}
        self.shipment_creates = 0

    def observe(self, event_id, kind, key=None, pair=None):
        fingerprint = (kind, key, pair)
        if event_id in self.seen:
            if self.seen[event_id] != fingerprint:
                raise ValueError('Identifiant de notification réutilisé avec un autre contenu')
            return 'DUPLICATE'
        if kind != 'Shipment':
            self.seen[event_id] = fingerprint
            return 'IGNORED'
        if key not in self.packages:
            raise ValueError('Identité non rapprochée')
        self.seen[event_id] = fingerprint
        p = self.packages[key]
        p['generation'] += 1
        p['dirty'] = True
        # L'observation n'écrase pas seule la valeur publiée.
        return (key, p['generation'])

    def reconcile(self, token, source_key, pair):
        key, generation = token
        if source_key != key:
            raise ValueError('Lecture d’une autre identité')
        p = self.packages[key]
        if p['generation'] != generation:
            return 'STALE_READ'
        if not isinstance(pair, tuple) or len(pair) != 2 or any(not isinstance(x, str) or not x for x in pair):
            raise ValueError('Couple transporteur/suivi incomplet')
        if not p['dirty'] and p['pair'] != pair:
            raise ValueError('Lecture déjà clôturée : nouvelle observation nécessaire')
        if p['pair'] == pair:
            p['dirty'] = False
            return 'UNCHANGED'
        self.history.append((key, p['revision'], p['pair']))
        p['pair'] = pair
        p['revision'] += 1
        p['dirty'] = False
        effect_key = (key, p['revision'], 'CORRECTION_SUIVI')
        self.outbox[effect_key] = pair
        return 'UPDATED'

    def dispatchable(self, effect_key):
        key, revision, _ = effect_key
        p = self.packages[key]
        return not p['dirty'] and p['revision'] == revision and self.outbox[effect_key] == p['pair']


def main():
    checks = 0
    def check(value):
        nonlocal checks
        assert value
        checks += 1
    def rejects(fn):
        try:
            fn()
        except ValueError:
            check(True)
        else:
            raise AssertionError('Rejet attendu')
    p = Projection(); baseline = deepcopy(p.packages[P2])
    t1 = p.observe('N1', 'Shipment', P1, ('C2', '009'))
    check(t1 == (P1, 1))
    check(p.packages[P1]['pair'] == ('C1', '001'))
    check(p.observe('N1', 'Shipment', P1, ('C2', '009')) == 'DUPLICATE')
    check(p.packages[P1]['generation'] == 1)
    rejects(lambda: p.observe('N1', 'Shipment', P1, ('C2', '010')))
    t2 = p.observe('N2', 'Shipment', P1, ('C2', '010'))
    check(p.reconcile(t1, P1, ('C2', '009')) == 'STALE_READ')
    check(p.packages[P1]['pair'] == ('C1', '001'))
    check(p.reconcile(t2, P1, ('C2', '010')) == 'UPDATED')
    check(p.packages[P1]['revision'] == 2)
    check(len(p.packages) == 2)
    check(p.packages[P2] == baseline)
    check(len(p.outbox) == 1)
    e2 = (P1, 2, 'CORRECTION_SUIVI')
    check(p.dispatchable(e2))
    late = p.observe('N0-late', 'Shipment', P1, ('C1', '001'))
    check(not p.dispatchable(e2))
    check(p.reconcile(late, P1, ('C2', '010')) == 'UNCHANGED')
    check(len(p.outbox) == 1)
    check(p.dispatchable(e2))
    before = deepcopy(p.packages)
    check(p.observe('N-order', 'Order') == 'IGNORED')
    check(p.packages == before)
    unknown = ('VENDEUR-A', 'COM-42', 'EXP-7', 'COL-3')
    rejects(lambda: p.observe('N-unknown', 'Shipment', unknown, ('C1', '003')))
    wrong_seller = ('VENDEUR-B', 'COM-42', 'EXP-7', 'COL-1')
    rejects(lambda: p.observe('N-wrong', 'Shipment', wrong_seller, ('C2', '010')))
    t4 = p.observe('N3', 'Shipment', P1, ('C3', '010'))
    rejects(lambda: p.reconcile(t4, P2, ('C3', '010')))
    for invalid in [('C3', ''), ('', '010'), ('C3', None), ('C3', 10), ['C3', '010']]:
        rejects(lambda invalid=invalid: p.reconcile(t4, P1, invalid))
        check(p.packages[P1]['pair'] == ('C2', '010'))
    check(p.reconcile(t4, P1, ('C3', '010')) == 'UPDATED')
    check(p.packages[P1]['revision'] == 3)
    check(not p.dispatchable(e2))
    check(p.dispatchable((P1, 3, 'CORRECTION_SUIVI')))
    check(p.reconcile(t4, P1, ('C3', '010')) == 'UNCHANGED')
    rejects(lambda: p.reconcile(t4, P1, ('C2', '010')))
    check(len(p.outbox) == 2)
    check(len(p.history) == 2)
    check(p.history[0][2] == ('C1', '001'))
    check(p.history[1][2] == ('C2', '010'))
    check(p.shipment_creates == 0)
    check(p.packages[P2] == baseline)
    # Témoins incorrects : clé tracking, première valeur seulement, dernière arrivée.
    naive_by_tracking = {('C1', '001'), ('C2', '009'), ('C2', '010')}
    check(len(naive_by_tracking) == 3 and len([k for k in p.packages if k == P1]) == 1)
    check(('C1', '001') != p.packages[P1]['pair'])
    check(('C2', '009') != p.packages[P1]['pair'])
    check(('C2', '010')[1] == ('C3', '010')[1] and ('C2', '010') != ('C3', '010'))
    print(json.dumps({'checks': checks, 'packages': len(p.packages), 'shipmentCreates': p.shipment_creates,
                      'correctionIntents': len(p.outbox), 'currentPair': p.packages[P1]['pair'],
                      'currentRevision': p.packages[P1]['revision'], 'incorrectWitnesses': 4,
                      'externalEffects': False}, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()
