# -*- coding: utf-8 -*-
"""OUTIL LIBRE DU CHASSEUR : L'Ange de l'Eau (The Angel of the Water), Pierre V1j ; annexe B inchangee depuis la V1g.

Un seul fichier, Python 3.8+ et sa bibliotheque standard, rien d'autre. A utiliser
HORS LIGNE : debranchez Internet avant de taper vos reponses.

  python3 outil_chasseur.py                  menu interactif (francais)
  python3 outil_chasseur.py verifier         verifier des reponses sur un coffre
  python3 outil_chasseur.py forcer           retrouver UN mot manquant (pres de 5 000 essais)
  python3 outil_chasseur.py retirer          preparer et signer la transaction qui vide le coffre
  python3 outil_chasseur.py entrainement     les coffres d'entrainement (faux, publics)
  python3 outil_chasseur.py coffret COFFRET_CHASSE0_V1.json lire un COFFRET : sa regle (types, doublons, heures, rondes) et son calendrier
                                             (tout nom de fichier : COFFRET_AEDE_V1.json pour la chasse n° 1, ou autre ; le contenu fait foi)
  python3 outil_chasseur.py solution message.json           verifier une solution publiee (message signe de l'auteur)
  python3 outil_chasseur.py manifeste manifeste.json        lire un manifeste : son empreinte, sa signature, ses coffres
  python3 outil_chasseur.py test             autotests (vecteurs publics) ; « test --rapide » : sans les calculs scrypt
Options (a ajouter a une commande) :
  --chasse 0                          la chasse zero (le galop d'essai : coffres 1 a 3, novembre 2026)
  --manifeste manifeste_chasse0.json  le manifeste grave de la chasse : adresses des coffres, empreintes des aleas (controle
                                      du cri), engagements (controle d'une solution), empreinte du COFFRET
  --definition chasse_2.json          une chasse annoncee (n 2 a 999), par sa definition, ou par son manifeste
  --adresse-auteur bc1q...            l'adresse des messages de l'auteur (guichet 3/0), pour une solution signee
  --extraire DOSSIER                  (coffret) ecrit chaque verrou dans un fichier .age, pour l'outil officiel de drand

Il faut, a cote de ce fichier : LISTE_DES_CINQ_MILLE.txt (la liste publique des mots).
La formule (annexe B de la Pierre) : chaine = AEDE-G<n>:<numeros du 3>:<numeros du 17>:<alea>,
numeros sur 4 chiffres, ranges par ordre croissant, separes par des tirets ;
cle = sha256(scrypt(chaine, sel = AEDE-V1g-coffre-<n>, N = 2^20, r = 8, p = 1, 32 octets)),
rehachee tant qu'elle n'est pas une cle valide ; adresse = P2WPKH (bc1q...) de cette cle.
Toute autre chasse h que la n° 1 porte son numero : chaine AEDE-H<h>-G<n>:..., sel AEDE-V1g-chasse-<h>-coffre-<n>,
cri « AEDE:cri:H<h>:<n>:<alea> ». La chasse zero (h = 0) a trois coffres (1 a 3), joues en parallele, de 3 mots par enigme.
Un mot peut servir dans les deux enigmes d'un meme coffre ; il ne se repete jamais a l'interieur d'une enigme.
Une chasse annoncee (h de 2 a 999) se decrit par sa definition publique (chasse_<h>.json, aussi recopiee dans son manifeste) :
l'outil la lit, la controle, et donne l'empreinte SHA-256 du fichier (celle d'un manifeste se compare a son annonce
« AEDE:chasse:<h>:<empreinte> », gravee et signee par l'auteur).

Le cri : son alea est compare a son empreinte dans le manifeste AVANT tout calcul (un faux « AEDE:cri » peut etre grave
par n'importe qui ; il ne trompe pas un chasseur qui donne le manifeste).
Le COFFRET (decision de l'auteur du 2 octobre 2026) : les enigmes, les cris et DEUX INDICES par coffre, chacun sous un
verrou a date drand ; aucune solution. L'indice fort s'ouvre 30 jours apres le cri, le second indice 182 jours apres.
La solution d'un coffre n'est publiee qu'une fois le coffre pris, par un message signe de l'auteur dont la derniere ligne
est « AEDE:solution:<h>:<c>:<n3>:<n17>:<sel> » ; l'outil la verifie contre l'engagement sale grave au semis dans le
manifeste, et la signature BIP-322 de l'adresse des messages. Il ne s'en sert jamais pour remplir des reponses.
La regle de la course : tous ceux qui trouvent ont la meme cle ; le premier retrait confirme sur la chaine l'emporte.
Les regles communes (heures, lecture du COFFRET, engagement) sont recopiees de chasses.py (la Cave) et verifiees sur ses
vecteurs : l'outil n'importe rien.
"""
import base64, calendar, datetime, hashlib, hmac, json, os, re, sys, time, unicodedata

VERSION = '1.2 (Pierre V1j, 2 octobre 2026)'
CHASSE = 1                           # --chasse 0 pour la chasse zero
TYPES = ['pierre'] * 10 + ['cathedrale'] + ['pierre'] * 9 + ['cathedrale'] * 3 + ['final']
CHASSES = {                          # le miroir de chasses.py (la chasse n° 1 prend son annee A dans son manifeste)
    0: {'nom': 'La chasse zero', 'coffres': 3, 'types': ['pierre', 'pierre', 'pierre'], 'annee': 2026, 'mois': 11, 'parallele': True},
    1: {'nom': "L'Ange de l'Eau", 'coffres': 24, 'types': TYPES, 'annee': None, 'mois': 1, 'parallele': False},
}
ICI = os.path.dirname(os.path.abspath(__file__))
LISTE_SHA256 = '01622e7b67eb5faf9e17107e4ac16fd5aa8aa602c343ad78f46be52b586b8e2a'
SCRYPT = dict(n=2 ** 20, r=8, p=1, dklen=32, maxmem=2 ** 31 - 1)
MOTS_PAR_ENIGME = {'pierre': 3, 'cathedrale': 4, 'final': 4}
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
     0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)

# les heures de la Pierre (article 6) et du COFFRET (chasses.py)
HEURE_ENIGME = (18, 15, 5)                          # UTC, la seconde du bloc Genesis
DELAI_CRI = 3600                                    # le cri s'ouvre une heure apres l'enigme du 17
DELAIS_INDICE = {1: 30 * 86400, 2: 182 * 86400}     # rang 1 : l'indice fort (J+30 apres le cri) ; rang 2 : le second indice (J+182)
TYPES_ELEMENT = ('enigme', 'cri', 'indice')         # les elements d'un COFFRET ; le type « solution » n'existe plus
COFFRET_MAX_OCTETS = 8 * 1024 * 1024
QUICKNET = dict(                                    # le reseau drand des verrous (verrou_a_date.py)
    hash='52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
    public_key='83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a',
    period=3, genesis_time=1692803367, schemeID='bls-unchained-g1-rfc9380')
CHAMPS_DRAND = ('hash', 'public_key', 'period', 'genesis_time', 'schemeID')
H_MAX, COFFRES_MAX = 999, 48

# l'adresse des messages de l'auteur (guichet 3/0) : epinglee a la publication (comme GG_ANCRE.adresse_auteur de GodGift
# Core) ; tant qu'elle est vide, l'outil affiche l'adresse qui signe et demande de la comparer (ou --adresse-auteur).
ADRESSE_AUTEUR = None
MANIFESTE = None                     # le manifeste donne par --manifeste (ou --definition d'un manifeste), lu et controle
ADRESSE_MANIFESTE = None             # l'adresse qui a signe ce manifeste (signature_manifeste_chasse<h>.json, si elle est valide)

CRI_FAUX = ("ce cri n'est pas celui du semis : son alea n'a pas l'empreinte gravee dans le manifeste "
            "(un faux « AEDE:cri » peut etre grave par n'importe qui). Aucun calcul n'est fait : cherchez le vrai cri.")

# Coffres d'entrainement : FAUX, publics, jamais une vraie chasse. Leurs aleas sont donnes.
ENTRAINEMENT = [
    {'nom': "Coffre d'essai A (celui du mode d'emploi)", 'n': 1,
     'enigme_3': "Le jour, il brule sans jamais se montrer deux fois au meme endroit du ciel. La nuit, elle emprunte sa lumiere et ne la "
                 "rend jamais. Moi, je vous les rends tous les deux, a l'envers, sans rien garder. Qui sommes-nous ?",
     'enigme_17': "Dans nos contes, il porte une pierre rouge au front, et il la pose sur la berge avant de descendre boire. Nommez celui "
                  "qui rampe, ce qu'il depose, et ce qu'il boit.",
     'alea': hashlib.sha256(b'EXEMPLE DU MODE D EMPLOI - jamais une vraie chasse').hexdigest(),
     'adresse': 'bc1qhsml20xzevmqml9nvz9pw8p3ue5tj3z96ffq95'},
    {'nom': "Coffre d'essai B (une cathedrale : 4 mots par enigme)", 'n': 11,
     'enigme_3': "L'oiseau m'a donne de quoi ecrire ; la seiche, de quoi noircir ; l'arbre, de quoi tourner. Et a la fin, relie, "
                 "je deviens tout autre chose. Quatre mots.",
     'enigme_17': "Deux points cardinaux qui se tournent le dos, celle qui guide le marin quand la nuit tombe, et ce qu'on dessine pour "
                  "ne pas se perdre. Quatre mots.",
     'alea': hashlib.sha256(b'COFFRE D ESSAI B - jamais une vraie chasse').hexdigest(),
     'adresse': 'bc1q04c3p5gr07q6n0hg6gesucw2m73388248v4x7t'},
]
for _c in ENTRAINEMENT:              # l'empreinte de leur alea, comme dans un manifeste : le controle du cri s'y exerce aussi
    _c['empreinte_alea'] = hashlib.sha256(bytes.fromhex(_c['alea'])).hexdigest()


def _entier(x):
    return isinstance(x, int) and not isinstance(x, bool)


def _hex64(x):
    return isinstance(x, str) and re.fullmatch(r'[0-9a-f]{64}', x) is not None


def _sans_doublon(paires):
    d = {}
    for k, v in paires:
        if k in d:
            raise ValueError(f'cle JSON en double : {k!r}')
        d[k] = v
    return d


# ============================================================ la liste des cinq mille
def _plier(s):
    s = unicodedata.normalize('NFD', s.strip().lower())
    return ''.join(c for c in s if unicodedata.category(c) != 'Mn').replace('œ', 'oe').replace('æ', 'ae')


def charger_liste(chemin=None):
    chemin = chemin or next((p for p in (os.path.join(ICI, 'LISTE_DES_CINQ_MILLE.txt'),
                                         os.path.join(ICI, 'LISTE_DES_CINQ_MILLE_candidat.txt')) if os.path.exists(p)), None)
    if not chemin:
        raise SystemExit('LISTE_DES_CINQ_MILLE.txt est introuvable a cote de l outil.')
    brut = open(chemin, 'rb').read()
    if hashlib.sha256(brut).hexdigest() != LISTE_SHA256:
        raise SystemExit('ATTENTION : la liste des cinq mille n a pas la bonne empreinte. Telechargez-la sur angedeleau.com.')
    mots = {}
    for ligne in brut.decode('utf-8').splitlines():
        num, mot = ligne.split(' ', 1)
        mots[int(num)] = mot
    return mots


def numero(entree, liste, plies):
    """Accepte un numero (1 a 5000) ou un mot de la liste (accents et majuscules indifferents)."""
    e = entree.strip()
    if re.fullmatch(r'\d{1,4}', e):
        n = int(e)
        if not 1 <= n <= 5000:
            raise ValueError(f'{e} : un numero va de 1 a 5000')
        return n
    exact = [n for n, m in liste.items() if m == e.lower()]
    if exact:
        return exact[0]
    cands = plies.get(_plier(e), [])
    if len(cands) == 1:
        return cands[0]
    if len(cands) > 1:
        raise ValueError(f'« {e} » est ambigu : ' + ', '.join(f'{liste[c]} ({c})' for c in cands))
    raise ValueError(f'« {e} » n est pas dans la liste des cinq mille (verifiez la forme : singulier, masculin, infinitif)')


# ============================================================ les chasses annoncees (definition)
def _definition_valide(d):
    ent = _entier
    champs = {'numero', 'nom', 'coffres', 'types', 'euros', 'annee', 'mois', 'parallele', 'index_amorce', 'index_rendu', 'nourrie_par_les_ventes'}
    return (isinstance(d, dict) and set(d) == champs and isinstance(d.get('nom'), str) and 1 <= len(d['nom']) <= 60 and d['nom'].isprintable()
            and d.get('nourrie_par_les_ventes') is False and ent(d.get('numero')) and 2 <= d['numero'] <= 999 and ent(d.get('coffres')) and 1 <= d['coffres'] <= 48
            and len(f"AEDE:cri:H{d['numero']}:{d['coffres']}:") + 64 <= 80 and isinstance(d.get('types'), list) and len(d['types']) == d['coffres']
            and all(isinstance(x, str) and x in MOTS_PAR_ENIGME for x in d['types']) and isinstance(d.get('euros'), list) and len(d['euros']) == d['coffres']
            and all(ent(x) and 1 <= x <= 100_000 for x in d['euros'])
            and ent(d.get('annee')) and 2027 <= d['annee'] <= 2100 and ent(d.get('mois')) and 1 <= d['mois'] <= 12 and isinstance(d.get('parallele'), bool)
            and d.get('index_amorce') == 10000 + 80 * d['numero'] and d.get('index_rendu') == 10040 + 80 * d['numero'])


def lire_definition(chemin):
    """La definition d'une chasse annoncee (h de 2 a 999), lue dans chasse_<h>.json ou dans son manifeste, et controlee comme
    le fait chasses.py. Rend (h, definition, empreinte SHA-256 du fichier, vrai si c'est un manifeste)."""
    brut = open(chemin, 'rb').read()
    emp = hashlib.sha256(brut).hexdigest()
    try:
        d = json.loads(brut.decode('utf-8'))
    except ValueError:
        raise SystemExit('ce fichier n est pas du JSON : donnez chasse_<h>.json ou le manifeste de la chasse')
    manifeste = isinstance(d, dict) and 'definition' in d
    m, d = (d, d['definition']) if manifeste else (None, d)
    if not _definition_valide(d):
        raise SystemExit('definition de chasse invalide (champs de chasses.py, nom imprimable de 60 caracteres au plus, numero 2 a 999, 1 a 48 coffres, '
                         'types pierre / cathedrale / final, montants de 1 a 100 000 euros, annee 2027 a 2100, mois 1 a 12)')
    if manifeste and (m.get('chasse') != d['numero'] or [c.get('n') if isinstance(c, dict) else None for c in m.get('coffres', [])] != list(range(1, d['coffres'] + 1))):
        raise SystemExit('ce manifeste ne correspond pas a sa propre definition')
    return d['numero'], d, emp, manifeste


def inscrire_definition(d):
    """La chasse d'une definition controlee, connue ensuite de toutes les fonctions de l'outil (comme chasses.enregistrer)."""
    CHASSES[d['numero']] = {'nom': d['nom'], 'coffres': d['coffres'], 'types': list(d['types']), 'annee': d['annee'], 'mois': d['mois'],
                            'parallele': d['parallele']}
    return d['numero']


# ============================================================ les heures de la Pierre (recopiees de chasses.py)
def _chasse(h):
    if not _entier(h) or h not in CHASSES:
        raise ValueError(f'chasse {h!r} inconnue de cet outil (une chasse annoncee : --definition ou --manifeste)')
    return CHASSES[h]


def annee_de(h, A=None):
    """L'annee d'ouverture : celle de la chasse, ou A (lue dans le manifeste ou le COFFRET) pour la chasse n° 1."""
    a = _chasse(h)['annee']
    if a is not None:
        return a
    if not _entier(A):
        raise ValueError('chasse n° 1 : l annee A (celle du manifeste) est necessaire')
    return A


def heure_enigme(h, n, jour, A=None):
    """L'instant UTC de l'enigme du `jour` (3 ou 17) du coffre n : un coffre par mois a partir du mois de depart, ou, pour
    une chasse parallele (la chasse zero), tous les coffres au mois de depart."""
    c = _chasse(h)
    if not _entier(n) or not 1 <= n <= c['coffres'] or jour not in (3, 17):
        raise ValueError('coffre ou jour hors de la chasse')
    m0 = annee_de(h, A) * 12 + (c['mois'] - 1) + (0 if c['parallele'] else n - 1)
    return calendar.timegm((m0 // 12, m0 % 12 + 1, jour) + HEURE_ENIGME + (0, 0, 0))


def heure_prevue(h, typ, coffre, numero=None, A=None, rang=None):
    """L'instant ou la Pierre ouvre un element du COFFRET : une enigme (numero), le cri, ou un indice (rang 1 ou 2).
    Tout autre type (dont l'ancien « solution ») est refuse : ValueError."""
    if typ == 'enigme':
        if not _entier(numero) or (numero + 1) // 2 != coffre or rang is not None:
            raise ValueError('numero d enigme incoherent')
        return heure_enigme(h, coffre, 3 if numero % 2 else 17, A)
    if typ not in TYPES_ELEMENT:
        raise ValueError(f'type d element inconnu : {typ!r} (enigme, cri ou indice ; le type solution n existe plus)')
    if numero is not None:
        raise ValueError('seule une enigme porte un numero')
    t17 = heure_enigme(h, coffre, 17, A)
    if typ == 'cri':
        if rang is not None:
            raise ValueError('un cri n a pas de rang')
        return t17 + DELAI_CRI
    if not _entier(rang) or rang not in DELAIS_INDICE:
        raise ValueError('rang d indice : 1 (indice fort, J+30) ou 2 (second indice, J+182)')
    return t17 + DELAI_CRI + DELAIS_INDICE[rang]


def heure_element(h, e, A=None):
    if not isinstance(e, dict):
        raise ValueError('element de COFFRET : un objet JSON')
    return heure_prevue(h, e.get('type'), e.get('coffre'), e.get('numero'), A, e.get('rang'))


def marque_cri(h, n):
    return f'AEDE:cri:{n}:' if h == 1 else f'AEDE:cri:H{h}:{n}:'


def _ronde_a(chaine, t):
    """La premiere ronde drand publiee a l'instant t ou apres."""
    return -(-(t - chaine['genesis_time']) // chaine['period']) + 1


def _instant_de(chaine, r):
    return chaine['genesis_time'] + (r - 1) * chaine['period']


# ============================================================ la cle (annexe B)
def type_du_coffre(n, chasse=None):
    h = CHASSE if chasse is None else chasse
    if h != 1:
        c = CHASSES[h]
        if not 1 <= n <= c['coffres']:
            raise ValueError(f"la chasse {h} n'a que {c['coffres']} coffre(s)")
        return c['types'][n - 1]
    if not 1 <= n <= 24:
        raise ValueError('le numero de coffre va de 1 a 24')
    return TYPES[n - 1]


def _prefixe(n, h):
    return f'AEDE-G{n}' if h == 1 else f'AEDE-H{h}-G{n}'


def _sel(n, h):
    return f'AEDE-V1g-coffre-{n}' if h == 1 else f'AEDE-V1g-chasse-{h}-coffre-{n}'


def chaine_v3(n, nums3, nums17, alea, chasse=None):
    h = CHASSE if chasse is None else chasse
    m = MOTS_PAR_ENIGME[type_du_coffre(n, h)]
    for nom, nums in (('du 3', nums3), ('du 17', nums17)):
        if len(nums) != m:
            raise ValueError(f"l'enigme {nom} de ce coffre demande {m} mots, pas {len(nums)}")
        if len(set(nums)) != len(nums):
            raise ValueError(f"l'enigme {nom} contient deux fois le meme mot")
    alea = alea.strip().lower()
    if not re.fullmatch(r'[0-9a-f]{64}', alea):
        raise ValueError("l'alea fait exactement 64 caracteres 0-9 a-f (copiez-le depuis le cri)")
    f = lambda xs: '-'.join(f'{x:04d}' for x in sorted(xs))
    return f'{_prefixe(n, h)}:{f(nums3)}:{f(nums17)}:{alea}'


def cle_privee(n, chaine, chasse=None):
    h = CHASSE if chasse is None else chasse
    if not chaine.startswith(_prefixe(n, h) + ':'):
        raise ValueError('cette chaine n est pas celle de ce coffre de cette chasse')
    d = hashlib.scrypt(chaine.encode('ascii'), salt=_sel(n, h).encode('ascii'), **SCRYPT)
    o = hashlib.sha256(d).digest(); k = int.from_bytes(o, 'big')
    while k == 0 or k >= N:
        o = hashlib.sha256(o).digest(); k = int.from_bytes(o, 'big')
    return k


# ---- courbe secp256k1 (coordonnees jacobiennes)
def _jdbl(p):
    X, Y, Z = p
    if Y == 0 or Z == 0: return (0, 0, 0)
    S = 4 * X * Y * Y % P; M = 3 * X * X % P
    X3 = (M * M - 2 * S) % P
    return (X3, (M * (S - X3) - 8 * pow(Y, 4, P)) % P, 2 * Y * Z % P)


def _jadd(p, q):
    if p[2] == 0: return q
    if q[2] == 0: return p
    X1, Y1, Z1 = p; X2, Y2, Z2 = q
    U1 = X1 * Z2 * Z2 % P; U2 = X2 * Z1 * Z1 % P; S1 = Y1 * pow(Z2, 3, P) % P; S2 = Y2 * pow(Z1, 3, P) % P
    if U1 == U2:
        return _jdbl(p) if S1 == S2 else (0, 0, 0)
    Hh = (U2 - U1) % P; R = (S2 - S1) % P; H2 = Hh * Hh % P; H3 = Hh * H2 % P; U1H2 = U1 * H2 % P
    X3 = (R * R - H3 - 2 * U1H2) % P
    return (X3, (R * (U1H2 - X3) - S1 * H3) % P, Hh * Z1 * Z2 % P)


def _mulj(k, pt=G):
    acc = (0, 0, 0)
    for b in bin(k)[2:]:
        acc = _jdbl(acc)
        if b == '1': acc = _jadd(acc, (pt[0], pt[1], 1))
    return acc


def _mul(k, pt=G):
    X, Y, Z = _mulj(k, pt); zi = pow(Z, P - 2, P)
    return (X * zi * zi % P, Y * pow(zi, 3, P) % P)


def pub(k):
    x, y = _mul(k)
    return bytes([2 + (y & 1)]) + x.to_bytes(32, 'big')


def _point(pubb):
    """Le point d'une cle publique compressee (33 octets), refuse hors de la courbe."""
    if len(pubb) != 33 or pubb[0] not in (2, 3):
        raise ValueError('cle publique compressee attendue')
    x = int.from_bytes(pubb[1:], 'big')
    y2 = (x * x * x + 7) % P; y = pow(y2, (P + 1) // 4, P)
    if x >= P or y * y % P != y2:
        raise ValueError('cle publique hors de la courbe')
    return (x, y if (y & 1) == (pubb[0] & 1) else P - y)


# ---- RIPEMD-160 (repli pur si OpenSSL ne l'a plus)
_R1 = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,
       1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13]
_R2 = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,
       8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11]
_S1 = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,
       11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6]
_S2 = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,
       15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11]
_K1 = [0, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xA953FD4E]; _K2 = [0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0]
def _rol(x, n): return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def _f(j, x, y, z):
    if j < 16: return x ^ y ^ z
    if j < 32: return (x & y) | (~x & z & 0xFFFFFFFF)
    if j < 48: return (x | (~y & 0xFFFFFFFF)) ^ z
    if j < 64: return (x & z) | (y & (~z & 0xFFFFFFFF))
    return x ^ (y | (~z & 0xFFFFFFFF))
def _rmd_pur(m):
    h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; ml = len(m) * 8; m += b'\x80'
    while len(m) % 64 != 56: m += b'\x00'
    m += ml.to_bytes(8, 'little')
    for o in range(0, len(m), 64):
        x = [int.from_bytes(m[o + 4 * i:o + 4 * i + 4], 'little') for i in range(16)]
        a, b, c, d, e = h; A, B, C, D, E = h
        for j in range(80):
            t = (_rol((a + _f(j, b, c, d) + x[_R1[j]] + _K1[j // 16]) & 0xFFFFFFFF, _S1[j]) + e) & 0xFFFFFFFF
            a, b, c, d, e = e, t, b, _rol(c, 10), d
            t = (_rol((A + _f(79 - j, B, C, D) + x[_R2[j]] + _K2[j // 16]) & 0xFFFFFFFF, _S2[j]) + E) & 0xFFFFFFFF
            A, B, C, D, E = E, t, B, _rol(C, 10), D
        t = (h[1] + c + D) & 0xFFFFFFFF; h[1] = (h[2] + d + E) & 0xFFFFFFFF; h[2] = (h[3] + e + A) & 0xFFFFFFFF
        h[3] = (h[4] + a + B) & 0xFFFFFFFF; h[4] = (h[0] + b + C) & 0xFFFFFFFF; h[0] = t
    return b''.join(v.to_bytes(4, 'little') for v in h)
def h160(b):
    s = hashlib.sha256(b).digest()
    try: return hashlib.new('ripemd160', s).digest()
    except ValueError: return _rmd_pur(s)


# ---- bech32 / bech32m (BIP-173, BIP-350) et base58
CS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
def _pm(v):
    g = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; c = 1
    for x in v:
        t = c >> 25; c = (c & 0x1ffffff) << 5 ^ x
        for i in range(5): c ^= g[i] if (t >> i) & 1 else 0
    return c
def _hx(hrp): return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def _conv(data, a, b, pad=True):
    acc = bits = 0; out = []; mx = (1 << b) - 1
    for v in data:
        acc = (acc << a) | v; bits += a
        while bits >= b: bits -= b; out.append((acc >> bits) & mx)
    if pad and bits: out.append((acc << (b - bits)) & mx)
    elif not pad and (bits >= a or ((acc << (b - bits)) & mx)): raise ValueError('bourrage invalide')
    return out
def adresse_segwit(prog, v=0, hrp='bc'):
    d = [v] + _conv(prog, 8, 5); const = 1 if v == 0 else 0x2bc830a3
    pm = _pm(_hx(hrp) + d + [0] * 6) ^ const
    return hrp + '1' + ''.join(CS[x] for x in d + [(pm >> 5 * (5 - i)) & 31 for i in range(6)])
def adresse_p2wpkh(k): return adresse_segwit(h160(pub(k)))
B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
def script_de(adresse):
    """Script de sortie d'une adresse de destination (bc1q, bc1p, 1..., 3...). Refuse tout ce qui cloche."""
    a = adresse.strip()
    if a.lower().startswith('bc1'):
        if a != a.lower() and a != a.upper(): raise ValueError('adresse bech32 en casse mixte')
        a = a.lower(); hrp, _, data = a.rpartition('1')
        d = [CS.index(c) for c in data]
        c = _pm(_hx(hrp) + d)
        v = d[0]
        if (v == 0 and c != 1) or (v > 0 and c != 0x2bc830a3): raise ValueError('somme de controle de l adresse fausse')
        prog = bytes(_conv(d[1:-6], 5, 8, False))
        if v == 0 and len(prog) not in (20, 32): raise ValueError('adresse v0 invalide')
        if v > 16 or len(prog) < 2 or len(prog) > 40: raise ValueError('adresse invalide')
        return bytes([0 if v == 0 else 0x50 + v, len(prog)]) + prog
    n = 0
    for ch in a: n = n * 58 + B58.index(ch)
    raw = n.to_bytes(25, 'big')
    if hashlib.sha256(hashlib.sha256(raw[:-4]).digest()).digest()[:4] != raw[-4:]: raise ValueError('somme de controle de l adresse fausse')
    if raw[0] == 0: return b'\x76\xa9\x14' + raw[1:21] + b'\x88\xac'
    if raw[0] == 5: return b'\xa9\x14' + raw[1:21] + b'\x87'
    raise ValueError('adresse non reconnue (il faut une adresse Bitcoin du reseau principal)')


# ============================================================ signatures et transactions
def _vi(n):
    return bytes([n]) if n < 0xfd else (b'\xfd' + n.to_bytes(2, 'little') if n <= 0xffff else b'\xfe' + n.to_bytes(4, 'little'))


def _lire_vi(b, pos):
    if pos >= len(b): raise ValueError('donnees tronquees')
    p = b[pos]
    if p < 0xfd: return p, pos + 1
    n = {0xfd: 2, 0xfe: 4, 0xff: 8}[p]
    if pos + 1 + n > len(b): raise ValueError('donnees tronquees')
    return int.from_bytes(b[pos + 1:pos + 1 + n], 'little'), pos + 1 + n


def _d2(b): return hashlib.sha256(hashlib.sha256(b).digest()).digest()
def _signer(k, z):
    x = k.to_bytes(32, 'big'); m = (int.from_bytes(z, 'big') % N).to_bytes(32, 'big')
    V = b'\x01' * 32; Kk = b'\x00' * 32
    Kk = hmac.new(Kk, V + b'\x00' + x + m, hashlib.sha256).digest(); V = hmac.new(Kk, V, hashlib.sha256).digest()
    Kk = hmac.new(Kk, V + b'\x01' + x + m, hashlib.sha256).digest(); V = hmac.new(Kk, V, hashlib.sha256).digest()
    while True:
        V = hmac.new(Kk, V, hashlib.sha256).digest(); kk = int.from_bytes(V, 'big')
        if 1 <= kk < N:
            r = _mul(kk)[0] % N
            s = pow(kk, N - 2, N) * (int.from_bytes(z, 'big') + r * k) % N
            if r and s: break
        Kk = hmac.new(Kk, V + b'\x00', hashlib.sha256).digest(); V = hmac.new(Kk, V, hashlib.sha256).digest()
    if s > N // 2: s = N - s
    def der(i):
        b = i.to_bytes((i.bit_length() + 7) // 8, 'big')
        return b'\x02' + bytes([len(b) + (b[0] >= 0x80)]) + (b'\x00' if b[0] >= 0x80 else b'') + b
    body = der(r) + der(s)
    return b'\x30' + bytes([len(body)]) + body + b'\x01', r, s
def _verifier(pubb, z, r, s):
    """Verification ECDSA : r et s dans [1, n-1], cle publique sur la courbe."""
    if not (1 <= r < N and 1 <= s < N):
        return False
    try:
        q = _point(pubb)
    except ValueError:
        return False
    w = pow(s, N - 2, N); u1 = int.from_bytes(z, 'big') * w % N; u2 = r * w % N
    X, Y, Z = _jadd(_mulj(u1), _mulj(u2, q))
    if Z == 0:
        return False
    zi = pow(Z, P - 2, P)
    return X * zi * zi % P % N == r


def _lire_der(sig):
    """DER strict (BIP-66) + octet de sighash. Rend (r, s, sighash) ; ValueError sinon."""
    if len(sig) < 9 or sig[0] != 0x30 or sig[1] != len(sig) - 3:
        raise ValueError('signature DER difforme')
    pos, vals = 2, []
    for _ in range(2):
        if pos + 2 > len(sig) - 1 or sig[pos] != 0x02:
            raise ValueError('signature DER difforme')
        ln = sig[pos + 1]; v = sig[pos + 2:pos + 2 + ln]
        if ln == 0 or len(v) != ln or (v[0] & 0x80) or (ln > 1 and v[0] == 0 and not (v[1] & 0x80)):
            raise ValueError('entier DER non minimal')
        vals.append(int.from_bytes(v, 'big')); pos += 2 + ln
    if pos != len(sig) - 1:
        raise ValueError('signature DER difforme')
    return vals[0], vals[1], sig[-1]


TAG_BIP322 = hashlib.sha256(b'BIP0322-signed-message').digest()


def verifier_bip322(adresse, message, signature_b64):
    """BIP-322 « simple » pour une adresse P2WPKH (bc1q...), comme la Cave (stone5_v1g.verifier_bip322). Rend True ou False."""
    try:
        script = script_de(adresse)
        if len(script) != 22 or script[:2] != b'\x00\x14':
            return False
        pile_b = base64.b64decode(signature_b64, validate=True)
        nb, pos = _lire_vi(pile_b, 0); pile = []
        for _ in range(nb):
            ln, pos = _lire_vi(pile_b, pos)
            pile.append(pile_b[pos:pos + ln]); pos += ln
        if pos != len(pile_b) or nb != 2 or h160(pile[1]) != script[2:]:
            return False
        r, s, sh = _lire_der(pile[0])
        if sh != 1:
            return False
        mh = hashlib.sha256(TAG_BIP322 + TAG_BIP322 + message.encode('utf-8')).digest()
        zero4 = (0).to_bytes(4, 'little'); zero8 = (0).to_bytes(8, 'little')
        a_depenser = (zero4 + b'\x01' + b'\x00' * 32 + b'\xff\xff\xff\xff' + b'\x22\x00\x20' + mh + zero4
                      + b'\x01' + zero8 + _vi(len(script)) + script + zero4)              # to_spend (version 0, sans temoin)
        point = _d2(a_depenser) + zero4                                                  # to_sign depense to_spend:0
        pre = (zero4 + _d2(point) + _d2(zero4) + point + b'\x19\x76\xa9\x14' + script[2:] + b'\x88\xac' + zero8 + zero4
               + _d2(zero8 + b'\x01\x6a') + zero4 + (1).to_bytes(4, 'little'))           # BIP-143, SIGHASH_ALL, sortie OP_RETURN
        return _verifier(pile[1], _d2(pre), r, s)
    except (ValueError, IndexError, TypeError, KeyError, AttributeError):
        return False


def _decoder_tx(hexa):
    """Une transaction brute (hexadecimal) : rend (scripts de ses sorties, txid). ValueError si elle est difforme."""
    b = bytes.fromhex(hexa)
    pos = 6 if b[4:6] == b'\x00\x01' else 4
    debut = pos

    def lire(n):
        nonlocal pos
        if n < 0 or pos + n > len(b): raise ValueError('transaction tronquee')
        x = b[pos:pos + n]; pos += n
        return x

    def vi():
        nonlocal pos
        v, pos = _lire_vi(b, pos)
        return v
    n_ent = vi()
    if n_ent == 0: raise ValueError('transaction sans entree')
    for _ in range(n_ent):
        lire(36); lire(vi()); lire(4)
    sorties = []
    for _ in range(vi()):
        lire(8); sorties.append(lire(vi()))
    fin = pos
    if debut == 6:
        for _ in range(n_ent):
            for _ in range(vi()): lire(vi())
    lire(4)
    if pos != len(b): raise ValueError('octets en trop apres la transaction')
    return sorties, _d2(b[:4] + b[debut:fin] + b[-4:])[::-1].hex()


def _donnees_op_return(script):
    if len(script) < 2 or script[0] != 0x6a: return None
    op = script[1]
    if op <= 75: ln, pos = op, 2
    elif op == 0x4c and len(script) > 2: ln, pos = script[2], 3
    else: return None
    return script[pos:pos + ln] if pos + ln == len(script) else None


# ============================================================ le cri et son alea (protocole M3)
def lire_alea(entree, n, h=None):
    """L'alea du coffre n, depuis ce que le chasseur colle : ses 64 caracteres, la ligne du cri « AEDE:cri:...:<alea> »,
    la transaction du cri (hexadecimal), ou le contenu ouvert de l'element cri du COFFRET ({"coffre", "hex", "txid"}).
    Rend (alea, txid du cri ou None). ValueError si ce n'est pas un cri, ou si c'est celui d'un autre coffre ou d'une
    autre chasse."""
    h = CHASSE if h is None else h
    e, txid, txid_annonce = entree.strip(), None, None
    if e.startswith('{'):
        try:
            d = json.loads(e)
        except ValueError:
            raise ValueError('contenu du cri illisible (JSON attendu : coffre, hex, txid)')
        if not isinstance(d, dict) or not isinstance(d.get('hex'), str):
            raise ValueError('contenu du cri : il y faut sa transaction (« hex »)')
        if d.get('coffre') != n:
            raise ValueError(f"ce cri est celui du coffre {d.get('coffre')!r}, pas du coffre {n}")
        e, txid_annonce = d['hex'].strip(), d.get('txid')
    if len(e) > 64 and re.fullmatch(r'(?:[0-9a-fA-F]{2})+', e):
        try:
            sorties, txid = _decoder_tx(e)
            dons = [x for x in (_donnees_op_return(s) for s in sorties) if x is not None and x.startswith(b'AEDE:cri:')]
        except ValueError:                           # pas une transaction : le script ou les donnees de la sortie OP_RETURN
            brut = bytes.fromhex(e); i = brut.find(b'AEDE:cri:'); txid = None
            dons = [brut[i:]] if i >= 0 else []
        if len(dons) != 1:
            raise ValueError('aucun cri (« AEDE:cri:... ») dans ce texte hexadecimal')
        try:
            e = dons[0].decode('ascii')
        except UnicodeDecodeError:
            raise ValueError('cri illisible')
        if txid_annonce is not None and txid is not None and txid_annonce != txid:
            raise ValueError('le txid annonce ne correspond pas a la transaction du cri')
    if e.startswith('AEDE:cri:'):
        marque = marque_cri(h, n)
        if not e.startswith(marque):
            raise ValueError(f'ce cri n est pas celui du coffre {n} de la chasse n° {h} (le cri de ce coffre commence par « {marque} »)')
        e = e[len(marque):]
    a = e.lower()
    if not re.fullmatch(r'[0-9a-f]{64}', a):
        raise ValueError("l'alea fait exactement 64 caracteres 0-9 a-f (copiez-le depuis le cri)")
    return a, txid


def alea_tenu(manifeste, coffre, alea_hex):
    """Vrai si l'alea d'un cri est celui que le manifeste grave engage (empreinte_alea = SHA-256 de ses 32 octets)."""
    try:
        if not _hex64(alea_hex):
            return False
        cf = [x for x in manifeste['coffres'] if isinstance(x, dict) and x.get('n') == coffre]
        return len(cf) == 1 and cf[0].get('empreinte_alea') == hashlib.sha256(bytes.fromhex(alea_hex)).hexdigest()
    except (KeyError, TypeError, AttributeError):
        return False


def controle_alea(n, alea, manifeste=None, empreinte=None):
    """Le controle du cri (protocole M3), AVANT tout calcul : l'alea est compare a son empreinte du manifeste (ou a
    l'empreinte tapee). Rend True s'il est controle, False s'il n'y a rien a quoi le comparer ; ValueError(CRI_FAUX) sinon."""
    if manifeste is not None:
        if not alea_tenu(manifeste, n, alea):
            raise ValueError(CRI_FAUX)
        return True
    if empreinte:
        if hashlib.sha256(bytes.fromhex(alea)).hexdigest() != empreinte.strip().lower():
            raise ValueError(CRI_FAUX)
        return True
    return False


# ============================================================ le manifeste
def coffre_du_manifeste(manifeste, n):
    cf = [x for x in manifeste['coffres'] if x.get('n') == n]
    if len(cf) != 1:
        raise ValueError(f'le coffre {n} n est pas dans ce manifeste')
    return cf[0]


def lire_manifeste(chemin):
    """Le manifeste grave d'une chasse (JSON du semis : chasse, annee_A, coffres avec adresse, empreinte_alea, engagement...),
    controle. Une chasse annoncee (h de 2 a 999) y lit sa definition. Rend (manifeste, empreinte SHA-256 du fichier)."""
    brut = open(chemin, 'rb').read()
    emp = hashlib.sha256(brut).hexdigest()
    try:
        m = json.loads(brut.decode('utf-8'), object_pairs_hook=_sans_doublon)
    except (ValueError, RecursionError):
        raise SystemExit('manifeste illisible (JSON attendu, sans cle en double)')
    if not isinstance(m, dict) or not isinstance(m.get('coffres'), list):
        raise SystemExit('ce fichier n est pas un manifeste de chasse (il lui faut la liste de ses coffres)')
    h = m.get('chasse', 1)
    if isinstance(m.get('definition'), dict):
        if not _definition_valide(m['definition']) or m['definition']['numero'] != h:
            raise SystemExit('ce manifeste ne correspond pas a sa propre definition')
        inscrire_definition(m['definition'])
    if not _entier(h) or h not in CHASSES:
        raise SystemExit(f'manifeste d une chasse inconnue de cet outil ({h!r})')
    try:
        if not _entier(m.get('annee_A')) or m['annee_A'] != annee_de(h, m['annee_A']):
            raise ValueError
    except ValueError:
        raise SystemExit(f'manifeste : son annee n est pas celle de la chasse n° {h}')
    n_c = CHASSES[h]['coffres']
    if [c.get('n') if isinstance(c, dict) else None for c in m['coffres']] != list(range(1, n_c + 1)):
        raise SystemExit(f'manifeste : il faut les coffres 1 a {n_c} de la chasse n° {h}, dans l ordre')
    for c in m['coffres']:
        try:
            sc = script_de(c['adresse']) if isinstance(c.get('adresse'), str) else b''
        except ValueError:
            sc = b''
        if len(sc) != 22 or sc[:2] != b'\x00\x14' or not _hex64(c.get('empreinte_alea')) or ('engagement' in c and not _hex64(c['engagement'])) \
                or c.get('type', type_du_coffre(c['n'], h)) != type_du_coffre(c['n'], h):
            raise SystemExit(f"manifeste : coffre {c['n']} difforme (adresse bc1q, empreinte de l alea et engagement en 64 hexadecimaux, type)")
    return m, emp


def verifier_signature_manifeste(sig, emp):
    """La signature d'un manifeste (signature_manifeste_chasse<h>.json : texte, adresse, signature) : rend l'adresse qui a
    signe si le texte est exactement « AEDE:manifeste:<empreinte> » et la signature BIP-322 valide, sinon None."""
    if not isinstance(sig, dict) or sig.get('texte') != 'AEDE:manifeste:' + emp or not isinstance(sig.get('adresse'), str):
        return None
    return sig['adresse'] if verifier_bip322(sig['adresse'], sig['texte'], sig.get('signature', '')) else None


def charger_manifeste(chemin, dire=True):
    """--manifeste : lit et controle le manifeste, cherche sa signature a cote, et le retient pour toute la session."""
    global MANIFESTE, ADRESSE_MANIFESTE
    m, emp = lire_manifeste(chemin)
    MANIFESTE, ADRESSE_MANIFESTE = m, None
    h = m.get('chasse', 1)
    if dire:
        print(f'Manifeste de la chasse n° {h} : {len(m["coffres"])} coffre(s) ; empreinte SHA-256 du fichier : {emp}')
        print(f'   comparez-la a sa gravure sur la chaine : AEDE:manifeste:{emp}')
    fs = os.path.join(os.path.dirname(os.path.abspath(chemin)), f'signature_manifeste_chasse{h}.json')
    if os.path.exists(fs):
        try:
            sig = json.load(open(fs, encoding='utf-8'))
        except ValueError:
            sig = None
        ADRESSE_MANIFESTE = verifier_signature_manifeste(sig, emp)
        if dire:
            print(f'   signature (signature_manifeste_chasse{h}.json) : ' + (f'BIP-322 valide, adresse {ADRESSE_MANIFESTE}' if ADRESSE_MANIFESTE
                                                                         else 'FAUSSE ou d un autre manifeste : ne vous fiez pas a ce manifeste'))
    elif dire:
        print(f'   (sa signature signature_manifeste_chasse{h}.json n est pas a cote : seule la gravure le garantit)')
    if dire and ADRESSE_MANIFESTE and ADRESSE_AUTEUR:
        print('   ' + ("c'est l'adresse des messages de l'auteur" if ADRESSE_MANIFESTE == ADRESSE_AUTEUR else "ATTENTION : ce n'est PAS l'adresse des messages de l'auteur"))
    return m, emp


# ============================================================ le COFFRET (regle commune, recopiee de chasses.controler_coffret)
def strophe_tlock(arme):
    """(ronde, hash de la chaine drand) lus dans la seule strophe « -> tlock <ronde> <hash> » d'un verrou age arme.
    ValueError si le verrou n'est pas un fichier age arme a une seule strophe tlock."""
    if not isinstance(arme, str):
        raise ValueError('verrou : texte attendu')
    lignes = arme.strip().split('\n')
    if len(lignes) < 3 or lignes[0] != '-----BEGIN AGE ENCRYPTED FILE-----' or lignes[-1] != '-----END AGE ENCRYPTED FILE-----':
        raise ValueError('verrou : armure age attendue')
    try:
        brut = base64.b64decode(''.join(lignes[1:-1]), validate=True)
    except ValueError:
        raise ValueError('verrou : armure age illisible')
    fin = brut.find(b'\n---')
    tete = brut[:fin].split(b'\n') if fin > 0 else []
    if len(tete) < 2 or tete[0] != b'age-encryption.org/v1' or sum(1 for x in tete if x.startswith(b'->')) != 1:
        raise ValueError('verrou : en-tete age a une seule strophe attendu')
    m = re.fullmatch(rb'-> tlock ([1-9][0-9]{0,15}) ([0-9a-f]{64})', tete[1])
    if not m:
        raise ValueError('verrou : strophe tlock attendue')
    return int(m.group(1)), m.group(2).decode('ascii')


def controler_coffret(texte, manifeste, chaine=QUICKNET, A=None):
    """La regle de lecture d'un COFFRET (la meme pour la Cave, la librairie, GodGift Core et l'outil du chasseur).
    Refuse (ValueError, la raison en clair) : un JSON douteux, une autre chaine drand, une empreinte differente de celle du
    manifeste, un type inconnu (dont « solution »), un element en double ((enigme, numero), (cri, coffre), (indice, coffre,
    rang)), un element manquant, une heure d'ouverture ou une ronde qui n'est pas celle de la Pierre, une strophe tlock dont
    la ronde ou la chaine n'est pas celle annoncee. Rend le COFFRET lu (dict)."""
    if isinstance(texte, str):
        brut = texte.encode('utf-8')
    elif isinstance(texte, (bytes, bytearray)):
        brut = bytes(texte)
    else:
        raise ValueError('COFFRET : texte attendu')
    if len(brut) > COFFRET_MAX_OCTETS:
        raise ValueError('COFFRET trop gros')
    try:
        d = json.loads(brut.decode('utf-8'), object_pairs_hook=_sans_doublon)
    except (ValueError, RecursionError):
        raise ValueError('COFFRET illisible (JSON attendu, sans cle en double)')
    if not isinstance(d, dict) or d.get('coffret') != 'AEDE-V1' or not isinstance(d.get('elements'), list):
        raise ValueError('ce fichier n est pas un COFFRET AEDE-V1')
    h = d.get('chasse')
    if not _entier(h) or h not in CHASSES:
        raise ValueError(f'COFFRET d une chasse inconnue : {h!r} (une chasse annoncee : --definition ou --manifeste)')
    if manifeste is not None:
        if not isinstance(manifeste, dict) or manifeste.get('chasse', 1) != h:
            raise ValueError('le COFFRET et le manifeste ne sont pas de la meme chasse')
        mc = manifeste.get('coffret') if isinstance(manifeste.get('coffret'), dict) else {}
        if mc.get('sha256') is not None and mc['sha256'] != hashlib.sha256(brut).hexdigest():
            raise ValueError('l empreinte du COFFRET n est pas celle du manifeste grave')
        if mc.get('drand') is not None and mc['drand'] != chaine['hash']:
            raise ValueError('le manifeste annonce une autre chaine drand')
        if A is None:
            A = manifeste.get('annee_A')
    if A is None:
        A = d.get('annee_A')
    if not _entier(d.get('annee_A')) or d['annee_A'] != annee_de(h, A):
        raise ValueError('annee du COFFRET differente de celle de la chasse')
    dr = d.get('drand')
    if not isinstance(dr, dict) or any(dr.get(k) != chaine[k] for k in CHAMPS_DRAND):
        raise ValueError('le COFFRET n est pas verrouille sur la chaine drand attendue')
    n_c = CHASSES[h]['coffres']
    vus = set()
    for e in d['elements']:
        if not isinstance(e, dict):
            raise ValueError('element du COFFRET : un objet JSON')
        typ = e.get('type')
        if typ == 'solution':
            raise ValueError('COFFRET refuse : il contient un element « solution » (le type solution n existe plus)')
        if typ not in TYPES_ELEMENT:
            raise ValueError(f'COFFRET refuse : type d element inconnu {typ!r}')
        champs = {'type', 'coffre', 'ouverture_utc', 'ronde', 'verrou'} | ({'numero'} if typ == 'enigme' else {'rang'} if typ == 'indice' else set())
        if set(e) != champs:
            raise ValueError(f'element {typ} : champs attendus ' + ', '.join(sorted(champs)))
        c = e['coffre']
        if not _entier(c) or not 1 <= c <= n_c:
            raise ValueError(f'element {typ} : coffre hors de 1 a {n_c}')
        cle = (typ, e['numero']) if typ == 'enigme' else (typ, c, e['rang']) if typ == 'indice' else (typ, c)
        if cle in vus:
            raise ValueError(f'COFFRET refuse : element en double {cle}')
        vus.add(cle)
        prevue = heure_element(h, e, A)
        r = _ronde_a(chaine, prevue)
        if not _entier(e['ronde']) or e['ronde'] != r or not _entier(e['ouverture_utc']) or e['ouverture_utc'] != _instant_de(chaine, r):
            raise ValueError(f'element {cle} : ronde ou heure d ouverture differente de celle de la Pierre')
        rs, hs = strophe_tlock(e['verrou'])
        if rs != r or hs != chaine['hash']:
            raise ValueError(f'element {cle} : la strophe tlock du verrou ne porte pas la ronde annoncee')
    if len(vus) != 5 * n_c:
        raise ValueError(f'COFFRET incomplet : {len(vus)} elements, {5 * n_c} attendus (2 enigmes, 1 cri et 2 indices par coffre)')
    return d


# ============================================================ l'engagement sale et la solution signee (recopies de chasses.py)
RE_SEL = re.compile(r'[0-9a-f]{32}')
_NUM = r'[1-9][0-9]{0,3}'
RE_SOLUTION = re.compile(r'AEDE:solution:(0|[1-9][0-9]{0,2}):([1-9][0-9]?):(' + _NUM + r'(?:,' + _NUM + r'){2,3}):(' + _NUM + r'(?:,'
                         + _NUM + r'){2,3}):([0-9a-f]{32})')


def _numeros_tries(mots):
    if not isinstance(mots, (list, tuple)) or not 3 <= len(mots) <= 4 or not all(_entier(x) and 1 <= x <= 5000 for x in mots):
        raise ValueError('les mots d une enigme : 3 ou 4 numeros entiers de 1 a 5000')
    if len(set(mots)) != len(mots):
        raise ValueError('numeros en double dans une enigme')
    return sorted(mots)


def _corps_solution(h, coffre, mots3, mots17, sel_hex):
    if not _entier(h) or not 0 <= h <= H_MAX or not _entier(coffre) or not 1 <= coffre <= COFFRES_MAX:
        raise ValueError('chasse de 0 a 999, coffre de 1 a 48')
    if h in CHASSES and coffre > CHASSES[h]['coffres']:
        raise ValueError(f"la chasse {h} n a que {CHASSES[h]['coffres']} coffre(s)")
    n3, n17 = _numeros_tries(mots3), _numeros_tries(mots17)
    if len(n3) != len(n17):
        raise ValueError('les deux enigmes d un coffre ont le meme nombre de mots')
    if not isinstance(sel_hex, str) or not RE_SEL.fullmatch(sel_hex):
        raise ValueError('sel : 32 hexadecimaux minuscules')
    return f"{h}:{coffre}:{','.join(map(str, n3))}:{','.join(map(str, n17))}:{sel_hex}"


def engagement(h, coffre, mots3, mots17, sel_hex):
    """L'engagement sale d'un coffre, grave au semis dans le manifeste : SHA-256 de
    « AEDE:engagement:<h>:<c>:<n3>:<n17>:<sel> » (numeros tries, en decimal, separes par des virgules). 64 hexadecimaux."""
    return hashlib.sha256(('AEDE:engagement:' + _corps_solution(h, coffre, mots3, mots17, sel_hex)).encode('ascii')).hexdigest()


def ligne_solution(h, coffre, mots3, mots17, sel_hex):
    return 'AEDE:solution:' + _corps_solution(h, coffre, mots3, mots17, sel_hex)


def lire_solution(texte):
    """(h, coffre, n3, n17, sel) si la DERNIERE ligne du texte est une ligne AEDE:solution canonique, sinon None."""
    if not isinstance(texte, str):
        return None
    m = RE_SOLUTION.fullmatch(texte.split('\n')[-1])
    if not m:
        return None
    h, c = int(m.group(1)), int(m.group(2))
    n3, n17 = [int(x) for x in m.group(3).split(',')], [int(x) for x in m.group(4).split(',')]
    try:
        if ligne_solution(h, c, n3, n17, m.group(5)) != m.group(0) or n3 != sorted(set(n3)) or n17 != sorted(set(n17)):
            return None
    except ValueError:
        return None
    return h, c, n3, n17, m.group(5)


def engagement_tenu(texte, manifeste):
    """(h, coffre, True) si l'engagement recalcule depuis la ligne AEDE:solution est celui du manifeste grave, (h, coffre,
    False) sinon ; None si le texte ne publie aucune solution. Ne remplit jamais de reponses et ne declenche rien."""
    s = lire_solution(texte)
    if s is None:
        return None
    h, c, n3, n17, sel_hex = s
    try:
        if not isinstance(manifeste, dict) or manifeste.get('chasse', 1) != h:
            return h, c, False
        cf = [x for x in manifeste.get('coffres', []) if isinstance(x, dict) and x.get('n') == c]
        return h, c, len(cf) == 1 and cf[0].get('engagement') == engagement(h, c, n3, n17, sel_hex)
    except (ValueError, TypeError, AttributeError):
        return h, c, False


def _date_valide(x):
    if not isinstance(x, str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', x):
        return False
    try:
        datetime.date(int(x[:4]), int(x[5:7]), int(x[8:]))
        return True
    except ValueError:
        return False


def lire_message(chemin):
    """Un message signe de l'auteur, tel que la librairie le publie (JSON : adresse, date, texte, signature), ou, a defaut,
    un fichier texte (le texte seul, sans signature)."""
    brut = open(chemin, 'rb').read().decode('utf-8')
    try:
        d = json.loads(brut, object_pairs_hook=_sans_doublon)
    except (ValueError, RecursionError):
        d = None
    if isinstance(d, dict) and isinstance(d.get('texte'), str):
        return d
    return {'texte': brut.strip()}


def verifier_solution(msg, manifeste, adresse_auteur=None, adresse_manifeste=None):
    """Un message qui publie la solution d'un coffre pris : l'engagement (recalcule depuis sa derniere ligne, compare a
    celui du manifeste grave) et, s'il est signe, sa signature BIP-322 sur « AEDE:message:<AAAA-MM-JJ>:<texte> ».
    Rend un dict : solution (h, c, n3, n17, sel) ou None, engagement (True, False ou None), signature (True, False ou None si
    absente), adresse, et auteur (True ou False si une adresse de reference est connue, sinon None). Ne remplit jamais les
    reponses d'un chasseur et ne declenche aucun envoi."""
    texte = msg.get('texte') if isinstance(msg, dict) else None
    out = {'solution': lire_solution(texte), 'engagement': None, 'signature': None, 'adresse': None, 'auteur': None}
    if out['solution'] is not None:
        out['engagement'] = engagement_tenu(texte, manifeste)[2]
    if isinstance(msg, dict) and msg.get('signature') is not None:
        a, dt = msg.get('adresse'), msg.get('date')
        out['adresse'] = a if isinstance(a, str) else None
        out['signature'] = bool(out['adresse'] and _date_valide(dt) and verifier_bip322(a, f'AEDE:message:{dt}:{texte}', msg['signature']))
        ref = adresse_auteur or adresse_manifeste
        if out['signature'] and ref:
            out['auteur'] = (a == ref)
    return out


# ============================================================ la transaction de retrait
def construire_retrait(k, utxos, destination, taux):
    """utxos : liste de (txid_hex, vout, montant_sats) de l'adresse du coffre. Rend (hex, txid, frais, montant envoye)."""
    pk = pub(k); h = h160(pk); sc_dest = script_de(destination)
    ent = [(bytes.fromhex(t)[::-1], v, m) for t, v, m in utxos]
    total = sum(m for _, _, m in ent)
    vsize = 11 + 68 * len(ent) + 9 + len(sc_dest) + 1
    frais = int(taux * vsize + 0.999)
    envoye = total - frais
    if envoye < 546: raise ValueError('apres les frais, il ne reste rien a envoyer : verifiez les montants et le taux')
    seq = (0xFFFFFFFD).to_bytes(4, 'little')
    hp = _d2(b''.join(t + v.to_bytes(4, 'little') for t, v, _ in ent)); hs = _d2(seq * len(ent))
    sortie = envoye.to_bytes(8, 'little') + _vi(len(sc_dest)) + sc_dest; ho = _d2(sortie)
    temoins = b''
    for t, v, m in ent:
        pre = (2).to_bytes(4, 'little') + hp + hs + t + v.to_bytes(4, 'little') + b'\x19\x76\xa9\x14' + h + b'\x88\xac' \
              + m.to_bytes(8, 'little') + seq + ho + (0).to_bytes(4, 'little') + (1).to_bytes(4, 'little')
        z = _d2(pre); sig, r, s = _signer(k, z)
        if not _verifier(pk, z, r, s): raise SystemExit('signature incoherente : ARRET')
        temoins += b'\x02' + _vi(len(sig)) + sig + _vi(len(pk)) + pk
    corps = _vi(len(ent)) + b''.join(t + v.to_bytes(4, 'little') + b'\x00' + seq for t, v, _ in ent) + b'\x01' + sortie
    brut = (2).to_bytes(4, 'little') + b'\x00\x01' + corps + temoins + (0).to_bytes(4, 'little')
    txid = _d2((2).to_bytes(4, 'little') + corps + (0).to_bytes(4, 'little'))[::-1].hex()
    return brut.hex(), txid, frais, envoye


# ============================================================ le mode « forcer » (protocole M11)
def candidats(e3, e17):
    """Les mots a essayer a la place du seul « ? » : toute la liste, sauf les mots deja donnes dans LA MEME enigme.
    La Cave n'interdit la repetition qu'a l'interieur d'une enigme : un mot de l'autre enigme du coffre reste possible."""
    trous = [(0, i) for i, x in enumerate(e3) if x is None] + [(1, i) for i, x in enumerate(e17) if x is None]
    if len(trous) != 1:
        raise ValueError('il faut exactement un « ? » : au-dela, la force brute est hors de portee (c est voulu)')
    meme = e3 if trous[0][0] == 0 else e17
    connus = {x for x in meme if x is not None}
    return [c for c in range(1, 5001) if c not in connus]


def forcer(n, e3, e17, alea, attendu, chasse=None, cands=None, montrer=None):
    """Essaie chaque candidat a la place du « ? » ; rend (numero trouve, cle) ou (None, None)."""
    j, i = ([(0, i) for i, x in enumerate(e3) if x is None] + [(1, i) for i, x in enumerate(e17) if x is None])[0]
    cands = candidats(e3, e17) if cands is None else cands
    for essai, cand in enumerate(cands):
        a3, a17 = list(e3), list(e17)
        (a3 if j == 0 else a17)[i] = cand
        try:
            _, k, a, _ = calculer(n, a3, a17, alea, chasse)
        except ValueError:
            continue
        if montrer:
            montrer(essai, len(cands), cand)
        if a == attendu:
            return cand, k
    return None, None


# ============================================================ l'interface
def demander(invite, defaut=None):
    r = input(invite + (f' [{defaut}]' if defaut else '') + ' : ').strip()
    return r or (defaut or '')


def lire_reponses(invite, m, liste, plies):
    while True:
        brut = demander(f'{invite} ({m} mots ou numeros, separes par des espaces)')
        items = brut.replace(',', ' ').split()
        try:
            if len(items) != m: raise ValueError(f'il faut exactement {m} mots')
            nums = [numero(x, liste, plies) for x in items]
            print('   → ' + '  '.join(f'{liste[n]} = {n:04d}' for n in nums))
            return nums
        except ValueError as e:
            print('   ! ' + str(e))


def invite_coffre(h):
    return 'Numero du coffre (1 a 24)' if h == 1 else f"Numero du coffre de la chasse n° {h} (1 a {CHASSES[h]['coffres']})"


def calculer(n, nums3, nums17, alea, chasse=None):
    ch = chaine_v3(n, nums3, nums17, alea, chasse)
    t = time.time(); k = cle_privee(n, ch, chasse); a = adresse_p2wpkh(k)
    return ch, k, a, time.time() - t


def demander_alea(n, h):
    while True:
        brut = demander(f"Alea du cri (64 caracteres apres « {marque_cri(h, n)} », ou la ligne du cri entiere, ou sa transaction en hexadecimal)")
        try:
            alea, txid = lire_alea(brut, n, h)
            if txid:
                print(f'   → cri lu dans la transaction {txid}')
            return alea
        except ValueError as e:
            print('   ! ' + str(e))


def adresse_et_controle(n, h, alea, preset=None):
    """L'adresse attendue du coffre et le controle de l'alea (protocole M3), AVANT tout calcul. Rend (adresse, controle) ;
    ValueError(CRI_FAUX) si l'alea n'a pas l'empreinte du manifeste."""
    if preset:
        return preset['adresse'], controle_alea(n, alea, empreinte=preset['empreinte_alea'])
    if MANIFESTE is not None:
        cf = coffre_du_manifeste(MANIFESTE, n)
        print(f"   adresse du coffre {n} (manifeste) : {cf['adresse']}")
        return cf['adresse'], controle_alea(n, alea, MANIFESTE)
    attendu = demander('Adresse publiee du coffre (bc1q...)')
    while True:
        emp = demander("Empreinte de l'alea dans le manifeste (« empreinte_alea » du coffre, 64 caracteres ; Entree ou n si vous ne l'avez pas)").lower()
        if emp in ('', 'n', 'non') or _hex64(emp):
            break
        print('   ! 64 caracteres 0-9 a-f, ou Entree')
    ok = controle_alea(n, alea, empreinte=emp if _hex64(emp) else None)
    if not ok:
        print("   ATTENTION : alea NON controle (ni manifeste, ni empreinte). Pour le controler : --manifeste manifeste_chasse<h>.json.")
    return attendu, ok


def mode_verifier(liste, plies, preset=None):
    print("\nVERIFIER DES REPONSES. Debranchez Internet maintenant.")
    h = 1 if preset else CHASSE                     # les coffres d'entrainement sont des coffres de la chasse n° 1
    n = int(preset['n']) if preset else int(demander(invite_coffre(h)))
    if h == 0:
        print(f"Chasse n° {h} (la chasse zero) : coffre {n} sur {CHASSES[h]['coffres']}, joues en parallele.")
    elif h != 1:
        print(f"Chasse n° {h} ({CHASSES[h].get('nom', '')}) : coffre {n} sur {CHASSES[h]['coffres']}.")
    ty = type_du_coffre(n, h); m = MOTS_PAR_ENIGME[ty]
    print(f'Coffre {n} : {ty}, {m} mots par enigme.')
    nums3 = lire_reponses('Reponses a l enigme du 3', m, liste, plies)
    nums17 = lire_reponses('Reponses a l enigme du 17', m, liste, plies)
    alea = preset['alea'] if preset else demander_alea(n, h)
    try:
        attendu, controle = adresse_et_controle(n, h, alea, preset)
    except ValueError as e:
        print('\n   !!! ' + str(e)); return n, None
    if controle:
        print("   alea controle : c'est bien celui du semis (son empreinte est celle du manifeste).")
    print('Calcul en cours (quelques secondes, 1 Gio de memoire)...')
    ch, k, a, dt = calculer(n, nums3, nums17, alea, h)
    print(f'   chaine  : {ch}\n   adresse : {a}   ({dt:.1f} s)')
    if a == attendu.strip():
        print('\n   *** IDENTIQUES : vos reponses sont justes. ***')
        print('   La regle de la course : tous ceux qui trouvent ont la meme cle ; le premier retrait confirme sur la chaine')
        print('   l emporte. Preparez le retrait sans attendre.')
        return n, k
    print('\n   Differentes : au moins un mot est faux. Relisez les deux enigmes (ou essayez le mode « forcer » s il ne vous en manque qu un).')
    if not controle:
        print("   (l'alea n'a pas ete controle : un faux cri donnerait aussi « differentes ». Donnez le manifeste pour le controler.)")
    return n, None


def mode_forcer(liste, plies):
    print("\nFORCER UN MOT MANQUANT. Tapez « ? » a la place du mot inconnu (un seul). Pres de 5 000 essais de quelques secondes.")
    n = int(demander(invite_coffre(CHASSE))); m = MOTS_PAR_ENIGME[type_du_coffre(n)]
    def lire(invite):
        items = demander(f'{invite} ({m} mots, dont au plus un « ? »)').split()
        if len(items) != m: raise SystemExit(f'il faut {m} elements')
        return [None if x == '?' else numero(x, liste, plies) for x in items]
    e3, e17 = lire('Enigme du 3'), lire('Enigme du 17')
    try:
        cands = candidats(e3, e17)
    except ValueError as e:
        raise SystemExit(str(e))
    alea = demander_alea(n, CHASSE)
    try:
        attendu, controle = adresse_et_controle(n, CHASSE, alea)
    except ValueError as e:
        print('\n   !!! ' + str(e)); return n, None
    if controle:
        print("   alea controle : c'est bien celui du semis.")
    print(f'   {len(cands)} mots a essayer (tous, sauf ceux deja donnes dans la meme enigme : un mot de l autre enigme reste possible).')
    t0 = time.time()
    def montrer(essai, total, cand):
        if essai % 25 == 0:
            print(f'   essai {essai + 1:4d} sur {total} ({liste[cand]}), {(time.time() - t0) / (essai + 1):.1f} s par essai', end='\r')
    cand, k = forcer(n, e3, e17, alea, attendu.strip(), CHASSE, cands, montrer)
    if cand is not None:
        print(f'\n   *** TROUVE : le mot manquant est « {liste[cand]} » ({cand:04d}). ***')
        print('   La regle de la course : tous ceux qui trouvent ont la meme cle ; le premier retrait confirme l emporte.')
        return n, k
    print(f'\n   Aucun des {len(cands)} mots ne convient : un autre mot est faux.'); return n, None


def mode_retirer(n=None, k=None, liste=None, plies=None):
    print("\nRETIRER LES BITCOINS DU COFFRE. Tout se fait hors ligne ; vous diffuserez ensuite la transaction vous-meme.")
    if k is None:
        n, k = mode_verifier(liste, plies)
        if k is None: return
    print("Sur un explorateur (sur un autre appareil), relevez ce que contient l'adresse du coffre : pour chaque sortie non depensee,")
    print("son identifiant de transaction (txid), son numero de sortie (vout) et son montant en sats. Une ligne vide pour finir.")
    utxos = []
    while True:
        l = demander('   txid:vout:montant_sats')
        if not l: break
        try:
            t, v, m = l.split(':'); utxos.append((t.strip(), int(v), int(m)))
            assert len(bytes.fromhex(t.strip())) == 32
        except Exception:
            print('   ! format : 64 caracteres hexadecimaux:numero:montant'); utxos and utxos.pop()
    if not utxos: print('Rien a retirer.'); return
    dest = demander('Votre adresse de reception (bc1q..., bc1p..., 1..., 3...)')
    taux = float(demander('Frais en sat/vB (regardez les frais du moment ; plus haut = plus rapide)', '20'))
    hx, txid, frais, envoye = construire_retrait(k, utxos, dest, taux)
    print(f'\n   Montant envoye : {envoye} sats (frais {frais} sats)\n   txid : {txid}\n\n{hx}\n')
    print("   Diffusez ce texte (Broadcast / Push transaction), de preference directement a un mineur ou par un service")
    print("   d'envoi prive. La transaction autorise le remplacement (RBF) : si elle tarde, refaites-la avec des frais plus hauts.")
    print("   Tous ceux qui ont trouve ont la meme cle : seul le premier retrait confirme compte.")
    open(os.path.join(ICI, f'retrait_coffre_{n}.txt' if CHASSE == 1 else f'retrait_chasse{CHASSE}_coffre_{n}.txt'), 'w').write(hx + '\n')


def mode_entrainement(liste, plies):
    print("\nCOFFRES D'ENTRAINEMENT (faux, publics, jamais une vraie chasse).")
    for i, c in enumerate(ENTRAINEMENT, 1):
        print(f'\n{i}. {c["nom"]} (coffre {c["n"]}, {MOTS_PAR_ENIGME[type_du_coffre(c["n"], 1)]} mots par enigme)')
        print('   Enigme du 3  : ' + c['enigme_3']); print('   Enigme du 17 : ' + c['enigme_17'])
    choix = int(demander('\nQuel coffre tenter', '1')) - 1
    mode_verifier(liste, plies, preset=ENTRAINEMENT[choix])


NOMS_ELEMENT = {'cri': 'cri', 1: 'indice fort (J+30)', 2: 'second indice (J+182)'}


def _nom_element(e):
    if e['type'] == 'enigme':
        return f"enigme {e['numero']} (le {3 if e['numero'] % 2 else 17})"
    return NOMS_ELEMENT['cri'] if e['type'] == 'cri' else NOMS_ELEMENT[e['rang']]


def mode_coffret(chemin, extraire=None):
    print('\nLIRE UN COFFRET (enigmes, cris et indices sous verrou a date ; aucune solution).')
    chemin = chemin or demander('Fichier du COFFRET (tout nom : COFFRET_CHASSE0_V1.json, COFFRET_AEDE_V1.json...)')
    brut = open(chemin, 'rb').read()
    emp = hashlib.sha256(brut).hexdigest()
    print(f'COFFRET {os.path.basename(chemin)} : empreinte SHA-256 {emp}')
    print('   (le nom du fichier est libre : seuls son contenu et son empreinte comptent)')
    try:
        d = controler_coffret(brut, MANIFESTE, QUICKNET)
    except ValueError as e:
        raise SystemExit(f'COFFRET REFUSE : {e}')
    h = d['chasse']
    print(f"   regle de lecture tenue : chasse n° {h}, {len(d['elements'])} elements (2 enigmes, 1 cri et 2 indices par coffre),")
    print('   aucun element « solution », aucun doublon, chaque heure et chaque ronde tlock celles de la Pierre, reseau drand quicknet.')
    if MANIFESTE is None:
        print("   ATTENTION : sans le manifeste (--manifeste), l'empreinte du COFFRET et l'annee de la chasse ne sont pas controlees :")
        print("   comparez l'empreinte ci-dessus a celle du manifeste grave (« coffret » : « sha256 »).")
    elif isinstance(MANIFESTE.get('coffret'), dict) and MANIFESTE['coffret'].get('sha256') == emp:
        print('   empreinte identique a celle du manifeste grave.')
    else:
        print("   ATTENTION : ce manifeste ne donne pas l'empreinte du COFFRET : comparez-la vous-meme.")
    maintenant = time.time()
    print('\n   Calendrier (heure UTC de la ronde drand qui ouvre chaque verrou) :')
    for e in sorted(d['elements'], key=lambda e: (e['ouverture_utc'], e['coffre'], TYPES_ELEMENT.index(e['type']), e.get('numero') or e.get('rang') or 0)):
        t = e['ouverture_utc']
        reste = t - maintenant
        etat = 'ouvrable' if reste <= 0 else (f'dans {int(-(-reste // 86400))} j' if reste > 86400 else f'dans {int(-(-reste // 3600))} h')
        print(f"   coffre {e['coffre']:2d}  {_nom_element(e):22s} {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(t))}  ronde {e['ronde']:9d}  {etat}")
    print('\n   Trente jours apres son cri, un coffre non pris recoit un indice fort ; six mois apres, un second indice.')
    print("   La solution n'est publiee qu'une fois le coffre pris, par un message signe de l'auteur, qui se verifie contre")
    print("   l'engagement grave au semis (commande « solution »).")
    if extraire:
        os.makedirs(extraire, exist_ok=True)
        for e in d['elements']:
            nom = f"{e['type']}_coffre{e['coffre']}" + (f"_enigme{e['numero']}" if e['type'] == 'enigme' else f"_rang{e['rang']}" if e['type'] == 'indice' else '')
            open(os.path.join(extraire, nom + '.age'), 'w').write(e['verrou'].strip() + '\n')
        print(f"\n   {len(d['elements'])} verrous ecrits dans {extraire} (fichiers .age).")
    print("   Un verrou s'ouvre, apres son heure, avec la signature drand de sa ronde (outil officiel : tlock-js, tle,")
    print("   timevault.drand.love ; ou GodGift Core). L'outil du chasseur, hors ligne, ne les ouvre pas" + ('.' if extraire else ' : --extraire DOSSIER les ecrit en fichiers .age.'))
    return d


def mode_manifeste(chemin):
    print('\nLIRE UN MANIFESTE.')
    if chemin:
        charger_manifeste(chemin)
    elif MANIFESTE is None:
        charger_manifeste(demander('Fichier du manifeste (manifeste_chasse<h>.json)'))
    m = MANIFESTE
    for c in m['coffres']:
        print(f"   coffre {c['n']:2d}  {c['adresse']}  empreinte de l'alea {c['empreinte_alea'][:16]}...  "
              + ('engagement grave' if c.get('engagement') else "sans engagement (semis d'avant le 2 octobre 2026)"))
    if isinstance(m.get('coffret'), dict) and m['coffret'].get('sha256'):
        print(f"   COFFRET : empreinte {m['coffret']['sha256']} (commande « coffret » pour le lire)")


def mode_solution(chemin, liste):
    print('\nVERIFIER UNE SOLUTION PUBLIEE (message signe de l auteur, une fois le coffre pris).')
    if MANIFESTE is None:
        raise SystemExit('il faut le manifeste grave de la chasse : ajoutez --manifeste manifeste_chasse<h>.json')
    chemin = chemin or demander('Fichier du message signe (JSON : adresse, date, texte, signature)')
    msg = lire_message(chemin)
    r = verifier_solution(msg, MANIFESTE, ADRESSE_AUTEUR, ADRESSE_MANIFESTE)
    if r['solution'] is None:
        raise SystemExit("ce message ne publie aucune solution (sa derniere ligne n'est pas « AEDE:solution:<h>:<c>:<n3>:<n17>:<sel> »)")
    h, c, n3, n17, _ = r['solution']
    mots = lambda ns: ', '.join(f'{x:04d} {liste[x]}' for x in ns)
    print(f'   Coffre {c} de la chasse n° {h} : enigme du 3 : {mots(n3)} ; enigme du 17 : {mots(n17)}.')
    print('   ' + ('engagement du semis tenu : ces mots et ce sel sont ceux graves au semis dans le manifeste.' if r['engagement']
                 else 'engagement FAUX : ces mots et ce sel ne sont pas ceux graves au semis (ou ce manifeste est celui d une autre chasse).'))
    if r['signature'] is None:
        print("   pas de signature : seul l'engagement est verifie (le message signe est sur la page de la chasse).")
    elif not r['signature']:
        print('   signature FAUSSE (ou date du message difforme) : ce message n est pas de l adresse qu il annonce.')
    else:
        print(f"   signature BIP-322 valide, adresse {r['adresse']}")
        if r['auteur'] is True:
            print("   c'est l'adresse des messages de l'auteur" + (' (epinglee).' if ADRESSE_AUTEUR else ' (celle qui a signe le manifeste).'))
        elif r['auteur'] is False:
            print("   ATTENTION : ce n'est PAS l'adresse des messages de l'auteur.")
        else:
            print("   comparez-la a l'adresse des messages de l'auteur (fiche de la caisse, page /verifier) ou donnez --adresse-auteur.")
    print("   L'outil ne remplit jamais vos reponses a partir de ce message et ne declenche aucun envoi : ce coffre est deja pris.")
    if r['engagement'] is False or r['signature'] is False or r['auteur'] is False:
        raise SystemExit(1)                         # code de sortie non nul : un controle a echoue
    return r


# ============================================================ autotests
# vecteurs independants, faits par la Cave (stone5_v1g.construire_cri, coffret_v1.signer_message ; graines FACTICES)
VECTEUR_ENGAGEMENT = {'chasse': 0, 'coffre': 2, 'enigme_3': [402, 17, 3999], 'enigme_17': [1201, 5, 88], 'sel': '00112233445566778899aabbccddeeff',
                      'texte': 'AEDE:engagement:0:2:17,402,3999:5,88,1201:00112233445566778899aabbccddeeff',
                      'engagement': '00ab0c3cac28096d7d4c286d3c0abbf8245e6efdece18ea188cc613ea1655bbd'}
VECTEUR_CRI = {'chasse': 0, 'coffre': 2, 'alea': '2b' * 32, 'txid': 'd6bcf4927a0ebe58978815105e62b329d54ec41ad0cc112cf8fa4883fc4864ef',
               'hex': '0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000fdffffff0200000000000000'
                      '00516a4c4e414544453a6372693a48303a323a32623262326232623262326232623262326232623262326232623262326232623262326232'
                      '623262326232623262326232623262326232623262326232623262f0000000000000000451024e730247304402206764b3f656f04af67c84'
                      'c1e050f212f246a9fa3f38657c14105b97a982a0f9bb022059984d623bd74079d26a52637abe56942118b8dab37ee1365c32b50d157c1fd1'
                      '012103f973a0b87062c389d125d8199e803b832b6ac6bf7867a4f6cd87506060fc4c58a999fc6a'}
VECTEUR_SOLUTION = json.loads(
    '{"message": "AEDE-v1", "adresse": "bc1qj4h4xdev0zk0vdl3p7yj3sw35w7kg9u7tyxgvx", "date": "2026-11-20", "texte": "Solution du coffre 2 de'
    ' la chasse n\\u00b0\\u00a00, pris le 20\\u00a0novembre\\u00a02026\\u00a0: \\u00e9nigme du 3\\u00a0: 17\\u00a0absurde, 402\\u00a0avoir,'
    ' 3999\\u00a0remettre\\u00a0; \\u00e9nigme du 17\\u00a0: 5\\u00a0abb\\u00e9, 88\\u00a0adulte, 1201\\u00a0co\\u00fbt.\\nAEDE:solution:0:2:'
    '17,402,3999:5,88,1201:00112233445566778899aabbccddeeff", "signature": "AkgwRQIhAKBTWMM4dDBdNUtxcDY/7x1MYgfOsTCm9zRVOAZpXCI+AiAiZu9PZTaYhHyB'
    'csTFGmd9E6d07EWKht0w3RL74gCMoQEhAmwM7VZSotsyq/tGiWhUYaDn0tLlxN68cm2ewI/HZFgr"}')
VECTEUR_SIGNATURE_MANIFESTE = {'texte': 'AEDE:manifeste:' + 'ab' * 32, 'adresse': 'bc1qj4h4xdev0zk0vdl3p7yj3sw35w7kg9u7tyxgvx',
                               'signature': 'AkcwRAIgdTIujj8eexSD8EOxWKM3O7PAKxGYxwCMhZWCJUaN3OECICkJfoEEBZ39rrhsFjqTzADQDl2b8eR85fe/DR0ANr7uASECbAztVlKi2zKr+0aJaFRhoOfS0uXE3rxybZ7Aj8dkWCs='}
BIP322_OFFICIELS = [('', 'AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI='),
                    ('Hello World', 'AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=')]
AUTOTESTS = 28               # le nombre de verts attendu ; 21 en mode rapide (sans les calculs scrypt de 1 Gio)


def _armure_factice(ronde, h_chaine, strophes=1):
    """Un verrou dont seul l'en-tete est vrai (le corps est factice) : assez pour la regle de lecture (comme chasses.py)."""
    tete = b'age-encryption.org/v1\n' + b''.join(b'-> tlock %d %s\nQUJD\n' % (ronde, h_chaine.encode()) for _ in range(strophes)) + b'--- AAAA\n' + b'\x00' * 40
    b = base64.b64encode(tete).decode()
    return '-----BEGIN AGE ENCRYPTED FILE-----\n' + '\n'.join(b[i:i + 64] for i in range(0, len(b), 64)) + '\n-----END AGE ENCRYPTED FILE-----\n'


def _refuse(f, erreurs=(ValueError,)):
    try:
        f()
    except erreurs:
        return True
    return False


def autotests(liste, rapide=False):
    ok = 0
    assert hashlib.scrypt(b'password', salt=b'NaCl', n=1024, r=8, p=16, dklen=64).hex().startswith('fdbabe1c9d3472007856e7190d01e9fe'); ok += 1
    assert _rmd_pur(b'abc').hex() == '8eb208f7e05d987a9b044a8e98c6b087f15a0bfc'; ok += 1
    assert adresse_p2wpkh(1) == 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; ok += 1                                 # BIP-173
    assert script_de('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4').hex() == '0014751e76e8199196d454941c45d1b3a323f1433bd6'; ok += 1
    assert script_de('bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0')[:2] == b'\x51\x20'; ok += 1      # BIP-350
    assert script_de('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')[:3] == b'\x76\xa9\x14'; ok += 1
    z = hashlib.sha256(b'essai').digest(); sig, r, s = _signer(123456789, z)
    assert _verifier(pub(123456789), z, r, s) and not _verifier(pub(123456789), z, r, s + 1) and not _verifier(pub(123456789), z, r, 0)
    assert not _verifier(b'\x02' + b'\xff' * 32, z, r, s) and not _verifier(pub(123456789)[:32], z, r, s); ok += 1
    assert liste[4398] == 'soleil' and liste[1598] == 'eau'; ok += 1
    vecteurs = os.path.join(ICI, 'VECTEURS_TEST_V1g.json')
    if not rapide and os.path.exists(vecteurs):
        for v in json.load(open(vecteurs)):
            assert adresse_p2wpkh(cle_privee(v['coffre'], v['chaine'], 1)) == v['adresse_mainnet'], ('vecteur', v['coffre']); ok += 1
    c = ENTRAINEMENT[0]
    if not rapide:
        assert calculer(1, [4398, 2773, 3018], [4323, 3513, 1598], c['alea'], 1)[2] == c['adresse']; ok += 1
        # la chasse zero : le vecteur public de chasses.py (memes mots et meme alea que le vecteur 1, autre adresse)
        ch0 = chaine_v3(1, [1, 2500, 5000], [873, 874, 4410], '2b' * 32, chasse=0)
        assert ch0.startswith('AEDE-H0-G1:') and adresse_p2wpkh(cle_privee(1, ch0, chasse=0)) == 'bc1qdju6t5686hnfx38kzj8anmdtw56yt7eu9j2s0y'; ok += 1
    # ses trois coffres : memes mots, meme alea, une cle par coffre (adresse du coffre 2 recalculee par cle_coffre_v1g, deux implementations)
    assert [type_du_coffre(n, 0) for n in (1, 2, 3)] == ['pierre'] * 3; ok += 1
    ch2 = chaine_v3(2, [1, 2500, 5000], [873, 874, 4410], '2b' * 32, chasse=0)
    if not rapide:
        assert ch2.startswith('AEDE-H0-G2:') and adresse_p2wpkh(cle_privee(2, ch2, chasse=0)) == 'bc1qcvckm9v49phshygh3f4r6kdr7736f0eup0jw6k'; ok += 1
    for faux in (lambda: type_du_coffre(4, 0), lambda: cle_privee(3, ch2, chasse=0)):
        assert _refuse(faux), 'coffre hors de la chasse zero, ou chaine d un autre coffre, accepte'
    ok += 1
    # une chasse annoncee : sa definition lue dans un fichier (ou dans son manifeste) ; ses cles portent son numero
    import tempfile
    dossier = tempfile.mkdtemp()
    d2 = {'numero': 2, 'nom': 'Essai', 'coffres': 2, 'types': ['pierre', 'cathedrale'], 'euros': [150, 400], 'annee': 2027, 'mois': 3,
          'parallele': False, 'index_amorce': 10160, 'index_rendu': 10200, 'nourrie_par_les_ventes': False}
    f = os.path.join(dossier, 'chasse_2.json'); open(f, 'w').write(json.dumps(d2))
    h, d, emp, man = lire_definition(f)
    assert (h, d['coffres'], man) == (2, 2, False) and emp == hashlib.sha256(open(f, 'rb').read()).hexdigest()
    inscrire_definition(d)
    try:
        c2 = chaine_v3(2, [1, 2, 3, 4], [5, 6, 7, 8], '2b' * 32, chasse=2)
        assert c2.startswith('AEDE-H2-G2:') and type_du_coffre(2, 2) == 'cathedrale'
        assert heure_prevue(2, 'indice', 2, rang=1) == calendar.timegm((2027, 5, 17, 19, 15, 5)) and marque_cri(2, 2) == 'AEDE:cri:H2:2:'
        if not rapide:
            assert adresse_p2wpkh(cle_privee(1, chaine_v3(1, [1, 2500, 5000], [873, 874, 4410], '2b' * 32, chasse=2), chasse=2)) not in (
                'bc1qdju6t5686hnfx38kzj8anmdtw56yt7eu9j2s0y', 'bc1qk90vyxp58842r6s6cdc05wjzklrhhhmx2y9egs')   # ni la chasse zero, ni la chasse n° 1
    finally:
        CHASSES.pop(2, None)
    for faux in (dict(d2, coffres=0, types=[], euros=[]), dict(d2, mois=13), dict(d2, types=['pierre', 'tour']), dict(d2, numero=1000)):
        open(f, 'w').write(json.dumps(faux))
        assert _refuse(lambda: lire_definition(f), SystemExit), 'definition fausse acceptee'
    ok += 1
    # ---- (2 octobre 2026) les heures de la Pierre, recopiees de chasses.py : enigmes, cri, indices de rang 1 et 2
    t3, t17 = calendar.timegm((2026, 11, 3, 18, 15, 5)), calendar.timegm((2026, 11, 17, 18, 15, 5))
    assert [heure_prevue(0, 'enigme', (k + 1) // 2, k) for k in range(1, 7)] == [t3, t17] * 3
    assert heure_prevue(0, 'cri', 3) == calendar.timegm((2026, 11, 17, 19, 15, 5)) and heure_prevue(0, 'indice', 1, rang=1) == calendar.timegm((2026, 12, 17, 19, 15, 5))
    assert heure_prevue(0, 'indice', 2, rang=2) - heure_prevue(0, 'cri', 2) == 182 * 86400 and heure_enigme(1, 13, 3, 2027) == calendar.timegm((2028, 1, 3, 18, 15, 5))
    assert _ronde_a(QUICKNET, heure_prevue(0, 'indice', 1, rang=1)) == 34910514 and _ronde_a(QUICKNET, t3) == 33642114                 # rondes de la Cave
    assert _ronde_a(QUICKNET, heure_prevue(1, 'indice', 24, A=2027, rang=2)) == 61204914
    for mauvais in (lambda: heure_prevue(0, 'solution', 1), lambda: heure_prevue(0, 'indice', 1), lambda: heure_prevue(0, 'indice', 1, rang=3),
                    lambda: heure_prevue(0, 'indice', 1, rang=True), lambda: heure_prevue(0, 'cri', 1, rang=1), lambda: heure_prevue(0, 'enigme', 3, 4),
                    lambda: heure_enigme(0, 4, 3), lambda: heure_enigme(1, 1, 3), lambda: heure_element(0, {'type': 'cri', 'coffre': [1]})):
        assert _refuse(mauvais), 'heure hors de la Pierre acceptee'
    ok += 1
    # ---- le COFFRET au nouveau format (spec. 3) : 2 enigmes, 1 cri, 2 indices par coffre ; regle de lecture de chasses.py
    ch = {'hash': 'ab' * 32, 'public_key': 'cd' * 96, 'period': 3, 'genesis_time': 1692803367, 'schemeID': 'bls-unchained-g1-rfc9380'}
    els = []
    for cf in (1, 2, 3):
        for e in ({'type': 'enigme', 'coffre': cf, 'numero': 2 * cf - 1}, {'type': 'enigme', 'coffre': cf, 'numero': 2 * cf}, {'type': 'cri', 'coffre': cf},
                  {'type': 'indice', 'coffre': cf, 'rang': 1}, {'type': 'indice', 'coffre': cf, 'rang': 2}):
            r = _ronde_a(ch, heure_element(0, e))
            els.append(dict(e, ronde=r, ouverture_utc=_instant_de(ch, r), verrou=_armure_factice(r, ch['hash'])))
    texte = json.dumps({'coffret': 'AEDE-V1', 'chasse': 0, 'annee_A': 2026, 'drand': ch, 'elements': els})
    man_c = {'chasse': 0, 'annee_A': 2026, 'coffret': {'sha256': hashlib.sha256(texte.encode()).hexdigest(), 'drand': ch['hash']}}
    lu = controler_coffret(texte, man_c, ch)
    assert len(lu['elements']) == 15 and sorted((e['coffre'], e['rang']) for e in lu['elements'] if e['type'] == 'indice') == [(c, r) for c in (1, 2, 3) for r in (1, 2)]
    assert controler_coffret(texte.encode(), None, ch) and strophe_tlock(els[0]['verrou']) == (els[0]['ronde'], ch['hash']); ok += 1
    i_ind = [k for k, e in enumerate(els) if e['type'] == 'indice' and e['rang'] == 1][0]
    def variante(fn):
        x = json.loads(texte); fn(x); return json.dumps(x)
    faux = {
        'solution': variante(lambda x: x['elements'][i_ind].update(type='solution') or x['elements'][i_ind].pop('rang')),
        'inconnu': variante(lambda x: x['elements'][i_ind].update(type='astuce')),
        'doublon': variante(lambda x: x['elements'].append(x['elements'][0])),
        'doublon d indice': variante(lambda x: x['elements'][i_ind + 1].update(rang=1, ronde=x['elements'][i_ind]['ronde'], ouverture_utc=x['elements'][i_ind]['ouverture_utc'], verrou=x['elements'][i_ind]['verrou'])),
        'manque': variante(lambda x: x['elements'].pop()),
        'rang 3': variante(lambda x: x['elements'][i_ind + 1].update(rang=3)),
        'ronde annoncee': variante(lambda x: x['elements'][2].update(ronde=x['elements'][2]['ronde'] - 1, ouverture_utc=x['elements'][2]['ouverture_utc'] - 3)),
        'strophe': variante(lambda x: x['elements'][2].update(verrou=_armure_factice(x['elements'][2]['ronde'] - 1, ch['hash']))),
        'deux strophes': variante(lambda x: x['elements'][2].update(verrou=_armure_factice(x['elements'][2]['ronde'], ch['hash'], 2))),
        'autre chaine dans la strophe': variante(lambda x: x['elements'][2].update(verrou=_armure_factice(x['elements'][2]['ronde'], 'ef' * 32))),
        'heure': variante(lambda x: x['elements'][2].update(ouverture_utc=x['elements'][2]['ouverture_utc'] + 3)),
        'indice au J+30 pour le rang 2': variante(lambda x: x['elements'][i_ind + 1].update(ronde=x['elements'][i_ind]['ronde'], ouverture_utc=x['elements'][i_ind]['ouverture_utc'], verrou=x['elements'][i_ind]['verrou'])),
        'champ en trop': variante(lambda x: x['elements'][0].update(texte_fr='en clair')),
        'autre drand': variante(lambda x: x['drand'].update(period=30)),
        'autre annee': variante(lambda x: x.update(annee_A=2027)),
        'cle JSON double': texte.replace('"coffret": "AEDE-V1"', '"coffret": "AEDE-V1", "coffret": "AEDE-V1"'),
        'pas du JSON': '[' * 100000,
    }
    for nom, t in faux.items():
        assert _refuse(lambda: controler_coffret(t, None, ch)), ('COFFRET faux accepte', nom)
    ok += 1
    for m in (dict(man_c, coffret={'sha256': '00' * 32}), dict(man_c, chasse=1), dict(man_c, coffret={'drand': 'ef' * 32})):
        assert _refuse(lambda: controler_coffret(texte, m, ch)), 'manifeste discordant accepte'
    ok += 1
    # ---- le controle du cri (protocole M3) : l'alea lu (64 caracteres, ligne du cri, transaction du cri, contenu ouvert du COFFRET),
    # compare a son empreinte du manifeste avant tout calcul
    vc = VECTEUR_CRI
    ligne = marque_cri(0, 2) + vc['alea']
    assert lire_alea(vc['alea'].upper(), 2, 0) == (vc['alea'], None) and lire_alea(ligne, 2, 0) == (vc['alea'], None)
    assert lire_alea(vc['hex'], 2, 0) == (vc['alea'], vc['txid']) and lire_alea(json.dumps({'coffre': 2, 'hex': vc['hex'], 'txid': vc['txid']}), 2, 0) == (vc['alea'], vc['txid'])
    assert lire_alea(ligne.encode().hex(), 2, 0) == (vc['alea'], None)
    for mauvais in (lambda: lire_alea(vc['hex'], 1, 0), lambda: lire_alea(vc['hex'], 2, 1), lambda: lire_alea('AEDE:cri:2:' + vc['alea'], 2, 0),
                    lambda: lire_alea(json.dumps({'coffre': 3, 'hex': vc['hex']}), 2, 0), lambda: lire_alea(json.dumps({'coffre': 2, 'hex': vc['hex'], 'txid': '00' * 32}), 2, 0),
                    lambda: lire_alea(vc['alea'][:-1], 2, 0), lambda: lire_alea('AEDE:cri:H0:22:' + vc['alea'], 2, 0)):
        assert _refuse(mauvais), 'cri d un autre coffre, d une autre chasse, ou difforme, accepte'
    m_al = {'chasse': 0, 'coffres': [{'n': 2, 'empreinte_alea': hashlib.sha256(bytes.fromhex(vc['alea'])).hexdigest()}]}
    assert alea_tenu(m_al, 2, vc['alea']) and not alea_tenu(m_al, 2, '2c' * 32) and not alea_tenu(m_al, 1, vc['alea']) and not alea_tenu(None, 2, vc['alea'])
    assert controle_alea(2, vc['alea'], m_al) is True and controle_alea(2, vc['alea'], empreinte=m_al['coffres'][0]['empreinte_alea']) is True
    assert controle_alea(2, vc['alea']) is False
    for mauvais in (lambda: controle_alea(2, '2c' * 32, m_al), lambda: controle_alea(2, '2c' * 32, empreinte=m_al['coffres'][0]['empreinte_alea'])):
        try:
            mauvais(); raise AssertionError('faux cri accepte')
        except ValueError as e:
            assert "n'est pas celui du semis" in str(e)
    ok += 1
    # ---- l'engagement sale (spec. 4) : le vecteur de chasses.py, la ligne AEDE:solution canonique, l'engagement tenu ou FAUX
    v = VECTEUR_ENGAGEMENT
    assert engagement(v['chasse'], v['coffre'], v['enigme_3'], v['enigme_17'], v['sel']) == v['engagement'] == hashlib.sha256(v['texte'].encode()).hexdigest()
    ls = ligne_solution(0, 2, [3999, 402, 17], [88, 5, 1201], v['sel'])
    assert ls == 'AEDE:solution:' + v['texte'][len('AEDE:engagement:'):] and lire_solution('Solution.\n' + ls) == (0, 2, [17, 402, 3999], [5, 88, 1201], v['sel'])
    for fx in (ls + '\n', ls + ' ', ls + '\nmerci', ls.replace(':17,', ':017,'), ls.replace('17,402', '402,17'), ls.replace(v['sel'], v['sel'].upper()),
               ls.replace('5,88,1201', '5,88'), ls.replace('5,88,1201', '5,5,1201'), ls.replace(':2:', ':4:'), ls.replace('3999', '5001'), 'AEDE:solution:', None, 42):
        assert lire_solution(fx) is None, fx
    man_e = {'chasse': 0, 'coffres': [{'n': 2, 'engagement': v['engagement']}]}
    assert engagement_tenu('x\n' + ls, man_e) == (0, 2, True) and engagement_tenu('rien', man_e) is None
    assert engagement_tenu('x\n' + ligne_solution(0, 2, [17, 402, 3998], [5, 88, 1201], v['sel']), man_e) == (0, 2, False)
    assert engagement_tenu('x\n' + ls, dict(man_e, chasse=1)) == (0, 2, False) and engagement_tenu('x\n' + ls, {'chasse': 0, 'coffres': []}) == (0, 2, False); ok += 1
    # ---- BIP-322 : les vecteurs officiels (cle L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k)
    adr = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l'
    assert all(verifier_bip322(adr, m, sg) for m, sg in BIP322_OFFICIELS)
    assert not verifier_bip322(adr, 'Hello World!', BIP322_OFFICIELS[1][1]) and not verifier_bip322(adr, '', BIP322_OFFICIELS[1][1])
    assert not verifier_bip322('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 'Hello World', BIP322_OFFICIELS[1][1]) and not verifier_bip322(adr, 'Hello World', 'pas du base64 !'); ok += 1
    # ---- la solution signee (spec. 4) : un message de la Cave (guichet 3/0 d'une graine FACTICE), verifie contre l'engagement du manifeste
    vs = VECTEUR_SOLUTION
    r = verifier_solution(vs, man_e)
    assert r['solution'] == (0, 2, [17, 402, 3999], [5, 88, 1201], v['sel']) and r['engagement'] is True and r['signature'] is True and r['auteur'] is None
    assert verifier_solution(vs, man_e, adresse_auteur=vs['adresse'])['auteur'] is True and verifier_solution(vs, man_e, adresse_auteur=adr)['auteur'] is False
    assert verifier_solution(vs, man_e, adresse_manifeste=VECTEUR_SIGNATURE_MANIFESTE['adresse'])['auteur'] is True
    assert verifier_solution(dict(vs, texte=vs['texte'].replace('le 20', 'le 21')), man_e)['signature'] is False
    assert verifier_solution(dict(vs, date='2026-11-21'), man_e)['signature'] is False and verifier_solution(dict(vs, date='2026-02-31'), man_e)['signature'] is False
    assert verifier_solution(dict(vs, adresse=adr), man_e)['signature'] is False
    assert verifier_solution(vs, {'chasse': 0, 'coffres': [{'n': 2, 'engagement': '00' * 32}]})['engagement'] is False
    r = verifier_solution({'texte': vs['texte']}, man_e)
    assert r['engagement'] is True and r['signature'] is None and verifier_solution({'texte': 'Bonjour.'}, man_e)['solution'] is None; ok += 1
    # ---- le manifeste : lu et controle ; sa signature (texte exact de la gravure, BIP-322)
    sm = VECTEUR_SIGNATURE_MANIFESTE
    assert verifier_signature_manifeste(sm, 'ab' * 32) == sm['adresse'] and verifier_signature_manifeste(sm, 'ac' * 32) is None
    assert verifier_signature_manifeste(dict(sm, signature=BIP322_OFFICIELS[1][1]), 'ab' * 32) is None
    a0 = adresse_p2wpkh(7)
    m0 = {'pierre': 'V1g', 'chasse': 0, 'annee_A': 2026, 'coffres': [{'n': i, 'type': 'pierre', 'adresse': a0, 'empreinte_alea': '1' * 64,
                                                                      'engagement': '2' * 64} for i in (1, 2, 3)]}
    fm = os.path.join(dossier, 'manifeste_chasse0.json'); open(fm, 'w').write(json.dumps(m0))
    lm, em = lire_manifeste(fm)
    assert lm == m0 and em == hashlib.sha256(open(fm, 'rb').read()).hexdigest()
    for mx in (dict(m0, annee_A=2027), dict(m0, coffres=m0['coffres'][:2]), dict(m0, chasse=5), dict(m0, coffres=[dict(m0['coffres'][0], empreinte_alea='1' * 63)] + m0['coffres'][1:]),
               dict(m0, coffres=[dict(m0['coffres'][0], adresse='1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')] + m0['coffres'][1:]), dict(m0, coffres=[dict(m0['coffres'][0], type='final')] + m0['coffres'][1:])):
        open(fm, 'w').write(json.dumps(mx))
        assert _refuse(lambda: lire_manifeste(fm), SystemExit), ('manifeste difforme accepte', mx)
    open(fm, 'w').write(json.dumps(m0).replace('"chasse": 0', '"chasse": 0, "chasse": 0'))
    assert _refuse(lambda: lire_manifeste(fm), SystemExit); ok += 1
    # ---- le mode « forcer » (protocole M11) : un mot de l'autre enigme du coffre reste un candidat ; jamais un mot de la meme enigme
    k3 = candidats([1, 2500, 5000], [873, None, 4410])
    assert len(k3) == 4998 and 1 in k3 and 2500 in k3 and 5000 in k3 and 873 not in k3 and 4410 not in k3
    k4 = candidats([12, None, 678, 910], [1111, 2222, 3333, 4444])
    assert len(k4) == 4997 and 1111 in k4 and 12 not in k4
    assert _refuse(lambda: candidats([1, None, 3], [None, 5, 6])) and _refuse(lambda: candidats([1, 2, 3], [4, 5, 6])); ok += 1
    if not rapide:
        # un coffre dont le mot manquant figure aussi dans l'autre enigme : l'ancien mode ne l'aurait jamais essaye
        attendu = calculer(1, [1, 2500, 5000], [873, 874, 1], '2b' * 32, 0)[2]
        trouve, k = forcer(1, [1, 2500, 5000], [873, 874, None], '2b' * 32, attendu, 0, cands=candidats([1, 2500, 5000], [873, 874, None])[:1])
        assert trouve == 1 and adresse_p2wpkh(k) == attendu; ok += 1
    return ok


# ============================================================ le lancement
def _prendre(args, nom):
    if nom not in args:
        return None
    i = args.index(nom)
    if i + 1 >= len(args):
        raise SystemExit(f'{nom} demande une valeur')
    v = args[i + 1]; del args[i:i + 2]
    return v


def main():
    print(f"Outil du chasseur, L'Ange de l'Eau, version {VERSION}. Tout est verifiable. Rien n'est promis.")
    liste = charger_liste(); plies = {}
    for n, m in liste.items(): plies.setdefault(_plier(m), []).append(n)
    global CHASSE, ADRESSE_AUTEUR
    args = sys.argv[1:]
    rapide = '--rapide' in args
    if rapide:
        args.remove('--rapide')
    adr = _prendre(args, '--adresse-auteur')
    if adr:
        try:
            if len(script_de(adr)) != 22 or not adr.lower().startswith('bc1q'): raise ValueError
        except ValueError:
            raise SystemExit('--adresse-auteur : une adresse bc1q... (P2WPKH) attendue')
        ADRESSE_AUTEUR = adr.strip().lower()
    extraire = _prendre(args, '--extraire')
    fdef, fman, fch = _prendre(args, '--definition'), _prendre(args, '--manifeste'), _prendre(args, '--chasse')
    if fch is not None and not re.fullmatch(r'[0-9]{1,3}', fch):
        raise SystemExit('--chasse : un numero de chasse (0 pour la chasse zero, 1 pour la grande chasse, 2 a 999 pour une chasse annoncee)')
    if fdef:                                        # une chasse annoncee (2 a 999) : sa definition, ou son manifeste
        h, d, emp, man = lire_definition(fdef)
        inscrire_definition(d)
        if fch is not None and int(fch) != h:
            raise SystemExit(f'--chasse et --definition ne designent pas la meme chasse ({h})')
        CHASSE = h
        print(f"Chasse n° {h} : {d.get('nom', '')}, {d['coffres']} coffre(s) ; empreinte SHA-256 du fichier : {emp}")
        if man:
            print(f'   (manifeste : comparez son annonce, gravee et signee : AEDE:chasse:{h}:{emp})')
            if not fman:
                charger_manifeste(fdef, dire=False)
    if fman:
        m, _ = charger_manifeste(fman)
        h = m.get('chasse', 1)
        if (fch is not None and int(fch) != h) or (fdef and h != CHASSE):
            raise SystemExit(f'le manifeste est celui de la chasse n° {h}, pas de la chasse demandee')
        CHASSE = h
    elif fch is not None and not fdef:
        CHASSE = int(fch)
        if CHASSE not in CHASSES:
            raise SystemExit(f'chasse {CHASSE} inconnue de cet outil : pour une chasse annoncee, donnez --definition chasse_{CHASSE}.json (ou son manifeste)')
    mode = args[0] if args else None
    fichier = args[1] if len(args) > 1 else None
    if not mode:
        print('\n 1. Verifier mes reponses\n 2. Forcer un mot manquant\n 3. Retirer les bitcoins d un coffre\n 4. Coffres d entrainement\n 5. Autotests'
              '\n 6. Lire un COFFRET\n 7. Verifier une solution publiee (message signe)\n 8. Lire un manifeste')
        mode = {'1': 'verifier', '2': 'forcer', '3': 'retirer', '4': 'entrainement', '5': 'test', '6': 'coffret', '7': 'solution',
                '8': 'manifeste'}.get(demander('Choix', '4'), 'entrainement')
    if mode == 'test':
        print('autotests verts :', autotests(liste, rapide), f'(attendus : {AUTOTESTS - 7 if rapide else AUTOTESTS})'); return
    if mode == 'verifier':
        n, k = mode_verifier(liste, plies)
        if k is not None and demander('Preparer le retrait maintenant ? (o/n)', 'o').lower().startswith('o'): mode_retirer(n, k)
    elif mode == 'forcer':
        n, k = mode_forcer(liste, plies)
        if k is not None and demander('Preparer le retrait maintenant ? (o/n)', 'o').lower().startswith('o'): mode_retirer(n, k)
    elif mode == 'retirer':
        mode_retirer(liste=liste, plies=plies)
    elif mode == 'coffret':
        mode_coffret(fichier, extraire)
    elif mode == 'solution':
        mode_solution(fichier, liste)
    elif mode == 'manifeste':
        mode_manifeste(fichier)
    else:
        mode_entrainement(liste, plies)


if __name__ == '__main__':
    try:
        main()
    except (KeyboardInterrupt, EOFError):
        print('\nAu revoir.')
