#!/usr/bin/env python3
"""Contre-exemples locaux de collecte incrémentale Airtable vers ERP.

Entrées normalisées et fictives, sans correspondance implicite avec un payload
Web API. Aucun moteur de formule Airtable, accès réseau ou export ERP.
Les empreintes décrivent des entrées locales; elles ne sont pas des révisions
natives, des verrous, des preuves de lecture atomique ou un journal d'événements.
Dans les fixtures, les dates des lignes suivent quantité et choix du tarif,
sans le lien réciproque vers le devis. Elles ne désignent pas implicitement
un suivi natif de tous les champs éditables de chaque ligne.
"""
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json

RULE = "sum_all_linked_lines_v1"


def require(condition, message):
    if not condition:
        raise ValueError(message)


def identity(value):
    require(isinstance(value, str) and bool(value.strip()), "identité absente")
    return value


def unsigned_integer(value, label):
    require(type(value) is int and value >= 0, label + " non qualifié")
    return value


def instant(value):
    require(isinstance(value, str), "horodatage absent")
    try:
        result = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as error:
        raise ValueError("horodatage illisible") from error
    require(result.utcoffset() is not None, "fuseau absent")
    return result.astimezone(timezone.utc)


def index_rows(rows, label):
    require(isinstance(rows, list), label + " non inventorié")
    result = {}
    for row in rows:
        require(isinstance(row, dict), label + " mal formé")
        key = identity(row.get("id"))
        require(key not in result, label + " identité ambiguë")
        result[key] = row
    return result


def observe(snapshot):
    """Évalue seulement la somme fictive et conserve les signaux séparément."""
    require(isinstance(snapshot, dict), "observation absente")
    require(snapshot.get("complete") is True, "lecture incomplète ou inconnue")
    require(snapshot.get("rule") == RULE, "règle locale non prise en charge")
    parent = snapshot.get("parent")
    require(isinstance(parent, dict), "devis absent")
    parent_id = identity(parent.get("id"))
    parent_clock = instant(parent.get("modified"))
    linked = parent.get("line_ids")
    require(isinstance(linked, list), "relations non inventoriées")
    require(all(isinstance(x, str) and x.strip() for x in linked), "relation invalide")
    require(len(linked) == len(set(linked)), "relation répétée")
    lines = index_rows(snapshot.get("lines"), "lignes")
    tariffs = index_rows(snapshot.get("tariffs"), "tarifs")
    inputs, direct_clocks, source_clocks = [], [], []
    total = 0
    for line_id in sorted(linked):
        require(line_id in lines, "ligne liée non lue")
        line = lines[line_id]
        tariff_id = identity(line.get("tariff_id"))
        require(tariff_id in tariffs, "tarif lié non lu")
        tariff = tariffs[tariff_id]
        quantity = unsigned_integer(line.get("quantity"), "quantité")
        cents = unsigned_integer(tariff.get("unit_cents"), "prix en centimes")
        require(tariff.get("currency") == "EUR", "devise hors profil local")
        direct_clocks.append(instant(line.get("modified")))
        source_clocks.append(instant(tariff.get("modified")))
        total += quantity * cents
        inputs.append([line_id, tariff_id, quantity, cents, "EUR"])
    observed = unsigned_integer(snapshot.get("observed_total_cents"), "montant observé")
    content = {"parent_id": parent_id, "rule": RULE, "inputs": inputs}
    encoded = json.dumps(content, sort_keys=True, separators=(",", ":")).encode()
    direct_clock = max(direct_clocks) if direct_clocks else None
    return {
        "parent_id": parent_id,
        "parent_clock": parent_clock,
        "direct_clock": direct_clock,
        "parent_and_direct_clock": max([parent_clock] + direct_clocks),
        "source_clock": max(source_clocks) if source_clocks else None,
        "dependency_content_sha256": hashlib.sha256(encoded).hexdigest(),
        "line_ids": sorted(linked),
        "expected_cents": total,
        "observed_cents": observed,
        "observed_matches_fixture_math": observed == total,
        "erp_authorized_or_completed": False,
    }


def later(before, after):
    """Une date indisponible reste indéterminée; aucune valeur zéro de secours."""
    return None if before is None or after is None else after > before


def compare(before, after):
    first, second = observe(before), observe(after)
    require(first["parent_id"] == second["parent_id"], "devis différents")
    return {
        "parent_clock_increased": later(first["parent_clock"], second["parent_clock"]),
        "direct_max_increased": later(first["direct_clock"], second["direct_clock"]),
        "parent_and_direct_max_increased": later(
            first["parent_and_direct_clock"], second["parent_and_direct_clock"]
        ),
        "tariff_max_increased": later(first["source_clock"], second["source_clock"]),
        "dependency_content_changed": (
            first["dependency_content_sha256"] != second["dependency_content_sha256"]
        ),
        "amount_changed": first["expected_cents"] != second["expected_cents"],
        "observed_matches_fixture_math": second["observed_matches_fixture_math"],
        "before_cents": first["expected_cents"],
        "after_cents": second["expected_cents"],
        "erp_authorized_or_completed": False,
    }


def baseline():
    return {
        "complete": True,
        "rule": RULE,
        "parent": {
            "id": "DEVIS-FICTIF-17",
            "line_ids": ["L1", "L2"],
            "modified": "2026-10-16T10:30:00Z",
        },
        "lines": [
            {"id": "L1", "tariff_id": "T1", "quantity": 2,
             "modified": "2026-10-16T10:10:00Z"},
            {"id": "L2", "tariff_id": "T2", "quantity": 1,
             "modified": "2026-10-16T10:20:00Z"},
            # Ces deux lignes existent déjà, mais ne sont pas liées au devis.
            {"id": "L3", "tariff_id": "T3", "quantity": 1,
             "modified": "2026-10-16T10:05:00Z"},
            {"id": "L4", "tariff_id": "T4", "quantity": 1,
             "modified": "2026-10-16T10:06:00Z"},
        ],
        "tariffs": [
            {"id": "T1", "unit_cents": 1000, "currency": "EUR",
             "modified": "2026-10-16T10:00:00Z"},
            {"id": "T2", "unit_cents": 500, "currency": "EUR",
             "modified": "2026-10-16T09:00:00Z"},
            {"id": "T3", "unit_cents": 300, "currency": "EUR",
             "modified": "2026-10-16T08:00:00Z"},
            {"id": "T4", "unit_cents": 500, "currency": "EUR",
             "modified": "2026-10-16T09:00:00Z"},
        ],
        # Champ du témoin normalisé, pas un nom de propriété API Airtable.
        "observed_total_cents": 2500,
    }


def run_checks():
    count = 0

    def check(condition):
        nonlocal count
        if not condition:
            raise AssertionError("échec du contrôle " + str(count + 1))
        count += 1

    def refused(snapshot):
        try:
            observe(snapshot)
        except ValueError:
            return True
        return False

    initial = baseline()
    unchanged = deepcopy(initial)
    check(observe(initial)["expected_cents"] == 2500)
    check(observe(initial)["observed_matches_fixture_math"])
    check(not compare(initial, unchanged)["dependency_content_changed"])
    check(not compare(initial, unchanged)["amount_changed"])

    nested = deepcopy(initial)
    nested["tariffs"][0].update(unit_cents=1200, modified="2026-10-16T11:00:00Z")
    nested["observed_total_cents"] = 2900
    n = compare(initial, nested)
    check(n["after_cents"] == 2900)
    check(n["amount_changed"])
    check(n["dependency_content_changed"])
    check(n["parent_clock_increased"] is False)
    check(n["direct_max_increased"] is False)
    check(n["parent_and_direct_max_increased"] is False)
    check(n["tariff_max_increased"] is True)
    check(n["observed_matches_fixture_math"])
    check(n["erp_authorized_or_completed"] is False)

    delayed = deepcopy(nested)
    delayed["observed_total_cents"] = 2500
    lag = compare(initial, delayed)
    check(lag["observed_matches_fixture_math"] is False)
    check(lag["after_cents"] == 2900)
    check(lag["erp_authorized_or_completed"] is False)

    quantity = deepcopy(initial)
    quantity["lines"][0].update(quantity=3, modified="2026-10-16T11:10:00Z")
    quantity["observed_total_cents"] = 3500
    q = compare(initial, quantity)
    check(q["after_cents"] == 3500)
    check(q["parent_clock_increased"] is False)
    check(q["direct_max_increased"] is True)

    added = deepcopy(initial)
    added["parent"].update(line_ids=["L1", "L2", "L3"], modified="2026-10-16T11:20:00Z")
    added["observed_total_cents"] = 2800
    a = compare(initial, added)
    check(a["parent_clock_increased"] is True)
    check(a["direct_max_increased"] is False)
    check(a["tariff_max_increased"] is False)
    check(a["after_cents"] == 2800)

    removed = deepcopy(initial)
    removed["parent"].update(line_ids=["L1"], modified="2026-10-16T11:30:00Z")
    removed["observed_total_cents"] = 2000
    r = compare(initial, removed)
    check(r["parent_clock_increased"] is True)
    check(r["direct_max_increased"] is False)
    check(observe(removed)["direct_clock"] < observe(initial)["direct_clock"])
    check(r["after_cents"] == 2000)

    substituted = deepcopy(initial)
    substituted["parent"].update(line_ids=["L1", "L4"], modified="2026-10-16T11:40:00Z")
    s = compare(initial, substituted)
    check(s["amount_changed"] is False)
    check(s["dependency_content_changed"] is True)
    check(s["direct_max_increased"] is False)

    reordered = deepcopy(initial)
    reordered["parent"]["line_ids"].reverse()
    reordered["lines"].reverse()
    reordered["tariffs"].reverse()
    check(compare(initial, reordered)["dependency_content_changed"] is False)
    check(compare(initial, reordered)["amount_changed"] is False)

    unrelated = deepcopy(initial)
    unrelated["tariffs"][2]["unit_cents"] = 999
    check(compare(initial, unrelated)["dependency_content_changed"] is False)
    check(compare(initial, unrelated)["amount_changed"] is False)

    equivalent_zone = deepcopy(initial)
    equivalent_zone["tariffs"][0]["modified"] = "2026-10-16T12:00:00+02:00"
    check(compare(initial, equivalent_zone)["tariff_max_increased"] is False)
    check(compare(initial, equivalent_zone)["dependency_content_changed"] is False)

    same_values_new_time = deepcopy(initial)
    same_values_new_time["tariffs"][0]["modified"] = "2026-10-16T12:00:00Z"
    same = compare(initial, same_values_new_time)
    check(same["tariff_max_increased"] is True)
    check(same["amount_changed"] is False)
    check(same["dependency_content_changed"] is False)

    zero = deepcopy(initial)
    zero["tariffs"][0]["unit_cents"] = 0
    zero["observed_total_cents"] = 500
    check(observe(zero)["expected_cents"] == 500)
    check(observe(zero)["observed_matches_fixture_math"])
    zero_quantity = deepcopy(initial)
    zero_quantity["lines"][0]["quantity"] = 0
    zero_quantity["observed_total_cents"] = 500
    check(observe(zero_quantity)["expected_cents"] == 500)
    check(observe(zero_quantity)["observed_matches_fixture_math"])

    for bad_price in [None, False, "0", -1, 1.5]:
        bad = deepcopy(initial)
        bad["tariffs"][0]["unit_cents"] = bad_price
        check(refused(bad))
    missing_price = deepcopy(initial)
    del missing_price["tariffs"][0]["unit_cents"]
    check(refused(missing_price))
    for complete in [False, None, 1]:
        incomplete = deepcopy(initial)
        incomplete["complete"] = complete
        check(refused(incomplete))
    for field in ["lines", "tariffs"]:
        missing = deepcopy(initial)
        missing[field].pop(0)
        check(refused(missing))
    duplicate = deepcopy(initial)
    duplicate["lines"].append(deepcopy(duplicate["lines"][0]))
    check(refused(duplicate))
    duplicate_link = deepcopy(initial)
    duplicate_link["parent"]["line_ids"].append("L1")
    check(refused(duplicate_link))
    for bad_time in [None, "", "2026-10-16T10:00:00"]:
        bad = deepcopy(initial)
        bad["tariffs"][0]["modified"] = bad_time
        check(refused(bad))
    for key, value in [("currency", "GBP")]:
        bad = deepcopy(initial)
        bad["tariffs"][0][key] = value
        check(refused(bad))
    for bad_quantity in [False, -1, "2"]:
        bad = deepcopy(initial)
        bad["lines"][0]["quantity"] = bad_quantity
        check(refused(bad))
    unknown_rule = deepcopy(initial)
    unknown_rule["rule"] = "sum_visible_view"
    check(refused(unknown_rule))

    empty = deepcopy(initial)
    empty["parent"].update(line_ids=[], modified="2026-10-16T12:00:00Z")
    empty["observed_total_cents"] = 0
    check(observe(empty)["expected_cents"] == 0)
    check(compare(initial, empty)["direct_max_increased"] is None)
    check(observe(empty)["erp_authorized_or_completed"] is False)
    check(initial == baseline())
    check(all(not result["erp_authorized_or_completed"] for result in [n, lag, q, a, r, s]))
    return {
        "checks_passed": count,
        "cases": {
            "nested_tariff_change": n,
            "old_observed_total": lag,
            "quantity_change": q,
            "older_line_added": a,
            "latest_line_removed": r,
            "same_amount_other_line": s,
            "same_values_new_timestamp": same,
        },
        "scope": {
            "inputs": "fictional normalized observations, not Airtable API payloads",
            "calculation": "local integer-cent sum, not Airtable formula execution",
            "native_airtable_calls": False,
            "erp_writes": False,
            "persistent_writes": False,
            "atomic_snapshot_or_revision_guarantee": False,
            "line_clock_scope": "quantity and tariff choice; reciprocal quote link excluded",
        },
    }


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