#!/usr/bin/env python3
"""Synthetic dataset for the tracee payment reconciliation demo, version 1.0.

Generates three CSV files in the current directory:
  ledger.csv          open receivables expected by a fictional payment institution
  bank-statement.csv  incoming credit lines from its bank and PSP statements
  answer-key.csv      the correct outcome for every statement line

Every name, reference, amount and date is invented. The seed is fixed, so
running this script again produces byte-identical files.

Usage:  python3 generate_dataset.py
"""

import csv
import random
from datetime import date, timedelta

SEED = 20261022
rng = random.Random(SEED)
START = date(2026, 9, 1)

PREFIX = ['Atelier', 'Maison', 'Groupe', 'Societe', 'Cabinet', 'Comptoir', 'Studio', 'Agence', 'Laboratoire', 'Ets']
CORE = ['Verdier', 'Lambert', 'Moreau', 'Garnier', 'Rousseau', 'Faure', 'Chevalier', 'Perrin', 'Morel', 'Girard',
        'Bonnet', 'Dupuis', 'Lemaire', 'Renaud', 'Barbier', 'Arnaud', 'Colin', 'Vidal', 'Caron', 'Picard',
        'Roger', 'Fabre', 'Aubert', 'Lucas', 'Brun', 'Marchand', 'Leclerc', 'Gauthier', 'Masson', 'Noel']
SECTOR = ['Logistique', 'Conseil', 'Distribution', 'Services', 'Industrie', 'Numerique', 'Transports', 'Energie']
LEGAL = ['SAS', 'SARL', 'SA', 'SASU']

counterparties = []
while len(counterparties) < 90:
    n = '%s %s %s %s' % (rng.choice(PREFIX), rng.choice(CORE), rng.choice(SECTOR), rng.choice(LEGAL))
    if n not in counterparties:
        counterparties.append(n)


def amount():
    return round(rng.choice([rng.uniform(80, 900), rng.uniform(900, 6000), rng.uniform(6000, 48000)]), 2)


def statement_name(n):
    """Bank statements often upper-case and truncate the payer name."""
    s = n.upper()
    return s[:rng.choice([18, 22, 26, 40])].strip()


def typo_ref(ref):
    """Two adjacent digits of the invoice number swapped, as a payer might mistype them."""
    head, num = ref.rsplit('-', 1)
    digits = list(num)
    for i in range(len(digits) - 1, 0, -1):
        if digits[i] != digits[i - 1]:
            digits[i], digits[i - 1] = digits[i - 1], digits[i]
            break
    return '%s-%s' % (head, ''.join(digits))


def noisy_ref(ref):
    year, num = ref.split('-')[1], ref.split('-')[2]
    core = str(int(num))
    return rng.choice([
        'INV %s %s PAYMENT' % (year, core),
        'FACTURE %s' % num,
        'REGLEMENT FACT %s/%s' % (core, year),
        'INV%s%s' % (year, num),
        'PAIEMENT INV-%s-%s' % (year, core),
    ])


ledger, bank, key = [], [], []
lid = bid = 0


def new_ledger(cp, amt, booked, due):
    global lid
    lid += 1
    ref = 'INV-2026-%05d' % (400 + lid)
    ledger.append({'ledger_id': 'L%04d' % lid, 'booking_date': booked.isoformat(), 'due_date': due.isoformat(),
                   'counterparty': cp, 'invoice_ref': ref, 'amount_eur': '%.2f' % amt})
    return ledger[-1]


def new_bank(day, name, info, amt, truth, case, outcome):
    global bid
    bid += 1
    line = {'line_id': 'B%04d' % bid, 'value_date': day.isoformat(), 'counterparty_name': name,
            'remittance_info': info, 'amount_eur': '%.2f' % amt}
    bank.append(line)
    key.append({'line_id': line['line_id'], 'ledger_ids': truth, 'case': case, 'expected': outcome})
    return line


PLAN = ['exact'] * 170 + ['ref_noise'] * 35 + ['fee'] * 25 + ['date_lag'] * 20 + ['split'] * 10 + \
       ['no_ref'] * 15 + ['fee_and_noise'] * 10 + ['unpaid'] * 25 + \
       ['same_payer_same_amount'] * 6 + ['third_party_payer'] * 6 + ['ref_typo'] * 8
rng.shuffle(PLAN)

paid_exact = []
for case in PLAN:
    cp = rng.choice(counterparties)
    booked = START + timedelta(days=rng.randint(0, 40))
    amt = amount()
    due = booked + timedelta(days=rng.randint(5, 25))
    L = new_ledger(cp, amt, booked, due)
    if case == 'unpaid':
        continue
    if case == 'exact':
        line = new_bank(due + timedelta(days=rng.randint(0, 1)), statement_name(cp), 'PAYMENT %s' % L['invoice_ref'], amt,
                        L['ledger_id'], case, 'match')
        paid_exact.append(line)
    elif case == 'ref_noise':
        new_bank(due + timedelta(days=rng.randint(0, 2)), statement_name(cp), noisy_ref(L['invoice_ref']), amt,
                 L['ledger_id'], case, 'match')
    elif case == 'fee':
        fee = round(rng.uniform(0.5, 3.5), 2)
        new_bank(due + timedelta(days=rng.randint(0, 2)), statement_name(cp), 'PAYMENT %s' % L['invoice_ref'], amt - fee,
                 L['ledger_id'], case, 'match')
    elif case == 'fee_and_noise':
        fee = round(rng.uniform(0.5, 3.5), 2)
        new_bank(due + timedelta(days=rng.randint(0, 2)), statement_name(cp), noisy_ref(L['invoice_ref']), amt - fee,
                 L['ledger_id'], case, 'match')
    elif case == 'date_lag':
        new_bank(due + timedelta(days=rng.randint(3, 5)), statement_name(cp), 'PAYMENT %s' % L['invoice_ref'], amt,
                 L['ledger_id'], case, 'match')
    elif case == 'no_ref':
        new_bank(due + timedelta(days=rng.randint(0, 2)), statement_name(cp),
                 rng.choice(['VIREMENT FOURNISSEUR', 'PAYMENT SEPTEMBER', 'REGLEMENT', 'TRANSFER']), amt,
                 L['ledger_id'], case, 'match')
    elif case == 'third_party_payer':
        factor = rng.choice(['NORD AFFACTURAGE SA', 'CREDIT FACTOR FRANCE', 'GROUPE HOLDING PAIEMENTS'])
        new_bank(due + timedelta(days=rng.randint(0, 1)), factor, 'PAYMENT %s' % L['invoice_ref'], amt,
                 L['ledger_id'], case, 'match')
    elif case == 'ref_typo':
        new_bank(due + timedelta(days=rng.randint(0, 1)), statement_name(cp), 'PAYMENT %s' % typo_ref(L['invoice_ref']), amt,
                 L['ledger_id'], case, 'match')
    elif case == 'same_payer_same_amount':
        # A second open invoice, same payer, same amount, due two weeks later.
        # The payment carries no reference and lands between the two due dates.
        new_ledger(cp, amt, booked + timedelta(days=14), due + timedelta(days=14))
        new_bank(due + timedelta(days=rng.randint(5, 9)), statement_name(cp), 'VIREMENT', amt,
                 L['ledger_id'], case, 'match')
    elif case == 'split':
        first = round(amt * rng.uniform(0.3, 0.7), 2)
        second = round(amt - first, 2)
        for part, lag in ((first, 0), (second, rng.randint(2, 6))):
            new_bank(due + timedelta(days=lag), statement_name(cp), 'PARTIAL PAYMENT %s' % L['invoice_ref'], part,
                     L['ledger_id'], case, 'match')

# Statement lines with no ledger entry behind them
for _ in range(12):
    cp = '%s %s %s %s' % (rng.choice(PREFIX), rng.choice(['Ortega', 'Novak', 'Haddad', 'Keller', 'Silva', 'Mertens']),
                          rng.choice(SECTOR), rng.choice(LEGAL))
    new_bank(START + timedelta(days=rng.randint(5, 60)), statement_name(cp),
             rng.choice(['PAYMENT REF %d' % rng.randint(10000, 99999), 'TRANSFER', 'ORDER %d' % rng.randint(100, 999)]),
             amount(), '', 'unknown_sender', 'exception')
dup_pairs = []
for src in rng.sample(paid_exact, 6):
    dup = new_bank(date.fromisoformat(src['value_date']), src['counterparty_name'], src['remittance_info'],
                   float(src['amount_eur']), '', 'duplicate', 'exception')
    dup_pairs.append((src, dup))
for target in rng.sample([l for l in ledger], 8):
    other = rng.choice([c for c in counterparties if c != target['counterparty']])
    new_bank(date.fromisoformat(target['due_date']) + timedelta(days=rng.randint(-3, 3)), statement_name(other),
             'TRANSFER', float(target['amount_eur']), '', 'look_alike_amount', 'exception')

for target in rng.sample([l for l in ledger], 6):
    parts = target['counterparty'].split()
    other = '%s %s %s %s' % (rng.choice([x for x in PREFIX if x != parts[0]]), parts[1],
                             rng.choice([x for x in SECTOR if x != parts[2]]), rng.choice(LEGAL))
    new_bank(date.fromisoformat(target['due_date']) + timedelta(days=rng.randint(-2, 2)), statement_name(other),
             'TRANSFER', float(target['amount_eur']), '', 'similar_name_decoy', 'exception')

# Statements arrive sorted by value date, not by case
order = sorted(range(len(bank)), key=lambda i: (bank[i]['value_date'], rng.random()))
bank = [bank[i] for i in order]
key = [key[i] for i in order]
# Two identical lines: whichever comes first in the statement is the original,
# the later one is the duplicate.
pos = {id(b): i for i, b in enumerate(bank)}
for src, dup in dup_pairs:
    if pos[id(dup)] < pos[id(src)]:
        ks, kd = key[pos[id(src)]], key[pos[id(dup)]]
        for f in ('ledger_ids', 'case', 'expected'):
            ks[f], kd[f] = kd[f], ks[f]
for i, (b, k) in enumerate(zip(bank, key), 1):
    b['line_id'] = k['line_id'] = 'B%04d' % i

for name, rows, fields in (('ledger.csv', ledger, ['ledger_id', 'booking_date', 'due_date', 'counterparty', 'invoice_ref', 'amount_eur']),
                           ('bank-statement.csv', bank, ['line_id', 'value_date', 'counterparty_name', 'remittance_info', 'amount_eur']),
                           ('answer-key.csv', key, ['line_id', 'ledger_ids', 'case', 'expected'])):
    with open(name, 'w', newline='', encoding='utf-8') as f:
        w = csv.DictWriter(f, fieldnames=fields, lineterminator='\n')
        w.writeheader()
        w.writerows(rows)
print('ledger entries: %d, statement lines: %d' % (len(ledger), len(bank)))
