#!/usr/bin/env python3
# verifie.py — vérificateur public de la Chaîne (protocole CHAINE/1)
# https://intranautes.ai/verifier
#
# Licence MIT
#
# Copyright (c) 2026 Julien Bédouret
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Vérifie l'intégrité de la Chaîne : continuité des empreintes, signatures
Ed25519 (minisign), horodatages OpenTimestamps (ancrage Bitcoin), cohérence
des dates.

Python 3.10+, bibliothèque standard uniquement. Dépendances externes
OPTIONNELLES : les binaires `minisign` et `ots` — en leur absence, le script
fait ce qu'il peut et dit explicitement ce qu'il n'a pas pu vérifier.

usage : verifie.py [--tout] [--maillon N] [--fichier CHEMIN --maillon N]
                   [--json] [--hors-ligne]

Codes de sortie : 0 intègre · 1 continuité brisée · 2 signature invalide ·
3 horodatage invalide · 4 erreur d'usage. La rupture d'ordre des dates
(passe 4) et les défauts structurels (trou de numérotation, type hors enum,
champ protocole altéré) comptent comme continuité brisée (code 1).
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path

PROTOCOLE = "CHAINE/1"
TYPES_AUTORISES = (
    "genese", "episode", "journal", "balise", "bulletin",
    "candidat", "verdict", "scelle", "procedure", "filigrane", "note",
)
GENESE_PRECEDENT = "0" * 64
CHAMPS_REQUIS = ("protocole", "maillon", "type", "date", "titre",
                 "empreintes", "publications", "precedent", "note")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
OTS_MAGIC = bytes.fromhex("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294")
ATTENTE_MAX = timedelta(days=7)  # au-delà, un « pending » signale un défaut d'ancrage

LIBELLES = {
    1: "continuité des empreintes",
    2: "signatures Ed25519",
    3: "horodatages OpenTimestamps",
    4: "cohérence des dates",
}


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def _abrege(sha: str) -> str:
    return f"{sha[:4]}…{sha[-4:]}"


def _parse_date(valeur):
    """ISO 8601 ; retourne (datetime|None, erreur|None). Fuseau obligatoire."""
    try:
        d = datetime.fromisoformat(str(valeur).replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return None, f"date illisible : {valeur!r}"
    if d.tzinfo is None:
        return None, f"date sans fuseau horaire : {valeur!r}"
    return d, None


# ---------------------------------------------------------------------------
# Modèle
# ---------------------------------------------------------------------------

@dataclass
class Maillon:
    numero: int
    path: Path
    brut: bytes
    data: dict | None          # None si le JSON est illisible
    erreurs: list[str] = field(default_factory=list)  # défauts structurels

    @property
    def nom(self) -> str:
        return self.path.name


@dataclass
class Passe:
    numero: int
    statut: str                # "OK" · "ÉCHEC" · "NON VÉRIFIÉ" · "HORS-LIGNE"
    detail: str
    echecs: list[str] = field(default_factory=list)
    code: int = 0              # code de sortie si la passe échoue

    @property
    def libelle(self) -> str:
        return LIBELLES[self.numero]

    def rendu(self) -> str:
        ligne = f"[{self.numero}/4] {self.libelle}… {self.statut} ({self.detail})"
        return "\n".join([ligne] + [f"  {e}" for e in self.echecs])


@dataclass
class Rapport:
    maillons: list[Maillon]
    passes: list[Passe]
    hors_ligne: bool
    partiel: int | None = None   # numéro du maillon si vérification --maillon N

    @property
    def code(self) -> int:
        # Précédence des codes : continuité/dates (1) > signature (2) > horodatage (3).
        codes = [p.code for p in self.passes if p.statut == "ÉCHEC"]
        for c in (1, 2, 3):
            if c in codes:
                return c
        return 0

    @property
    def tete(self) -> tuple[int, str] | None:
        # La tête de Chaîne (05 §4.5) ne s'établit que par un parcours complet :
        # une vérification partielle n'en annonce jamais une.
        if self.partiel is not None or not self.maillons:
            return None
        dernier = self.maillons[-1]
        return dernier.numero, sha256_bytes(dernier.brut)

    def _reserves(self) -> str:
        reserves = [p.libelle for p in self.passes if p.statut not in ("OK", "ÉCHEC")]
        return (f", sous réserve des vérifications non effectuées "
                f"({', '.join(reserves)})" if reserves else "")

    def verdict(self) -> str:
        if self.partiel is not None:
            sha = _abrege(sha256_bytes(self.maillons[0].brut)) if self.maillons else "?"
            if self.code == 0:
                return (f"VERDICT : maillon {self.partiel:04d} intègre"
                        f"{self._reserves()}. sha256 du maillon : {sha}")
            return (f"VERDICT : maillon {self.partiel:04d} NON intègre (code "
                    f"{self.code} — {LIBELLES.get(self.code, 'défaut')} en échec).")
        tete = self.tete
        suffixe = (f"Tête : maillon {tete[0]:04d}, sha256 {_abrege(tete[1])}"
                   if tete else "aucun maillon")
        if self.code == 0:
            return f"VERDICT : chaîne intègre{self._reserves()}. {suffixe}"
        return (f"VERDICT : chaîne NON intègre (code {self.code} — "
                f"{LIBELLES.get(self.code, 'défaut')} en échec). {suffixe}")

    def rendu(self) -> str:
        lignes = ["Chaîne — vérification"]
        if self.partiel is not None:
            lignes.append(f"maillon vérifié : {self.partiel:04d} "
                          "(vérification partielle — la chaîne complète n'est pas parcourue)")
        elif self.maillons:
            lignes.append(f"maillons trouvés : {len(self.maillons)} "
                          f"({self.maillons[0].numero:04d} → {self.maillons[-1].numero:04d})")
        else:
            lignes.append("maillons trouvés : 0")
        lignes += [p.rendu() for p in self.passes]
        lignes.append(self.verdict())
        return "\n".join(lignes)

    def as_json(self) -> dict:
        tete = self.tete
        verifie_seul = None
        if self.partiel is not None and self.maillons:
            verifie_seul = {"maillon": self.partiel,
                            "sha256": sha256_bytes(self.maillons[0].brut)}
        return {
            "code": self.code,
            "maillons": len(self.maillons),
            "premier": f"{self.maillons[0].numero:04d}" if self.maillons else None,
            "dernier": f"{self.maillons[-1].numero:04d}" if self.maillons else None,
            "hors_ligne": self.hors_ligne,
            "passes": [{"numero": p.numero, "libelle": p.libelle, "statut": p.statut,
                        "detail": p.detail, "echecs": p.echecs} for p in self.passes],
            "tete": ({"maillon": tete[0], "sha256": tete[1]} if tete else None),
            "maillon_verifie": verifie_seul,
            "verdict": self.verdict(),
        }


# ---------------------------------------------------------------------------
# Chargement
# ---------------------------------------------------------------------------

def _charger_fichier(path: Path) -> Maillon:
    """Charge un NNNN.json et relève ses défauts structurels propres."""
    numero = int(path.stem)
    brut = path.read_bytes()
    try:
        data = json.loads(brut.decode("utf-8"))
    except (ValueError, UnicodeDecodeError) as exc:
        return Maillon(numero, path, brut, None,
                       [f"maillon {path.stem} : JSON illisible ({exc})"])
    erreurs = []
    for champ in CHAMPS_REQUIS:
        if champ not in data:
            erreurs.append(f"maillon {path.stem} : champ « {champ} » absent")
    if data.get("protocole") != PROTOCOLE:
        erreurs.append(f"maillon {path.stem} : protocole attendu « {PROTOCOLE} », "
                       f"observé {data.get('protocole')!r} (casse exacte exigée)")
    if data.get("maillon") != numero:
        erreurs.append(f"maillon {path.stem} : numéro interne attendu {numero}, "
                       f"observé {data.get('maillon')!r}")
    if data.get("type") not in TYPES_AUTORISES:
        erreurs.append(f"maillon {path.stem} : type hors enum, observé "
                       f"{data.get('type')!r} (attendu l'un de : "
                       f"{', '.join(TYPES_AUTORISES)})")
    for e in data.get("empreintes") or []:
        if not (isinstance(e, dict) and isinstance(e.get("sha256"), str)
                and _SHA256_RE.match(e["sha256"])):
            erreurs.append(f"maillon {path.stem} : empreinte malformée : {e!r}")
    return Maillon(numero, path, brut, data, erreurs)


def charger(dossier: Path) -> tuple[list[Maillon], list[str]]:
    """Charge les NNNN.json triés ; retourne (maillons, défauts globaux).
    Les trous de numérotation sont détectés par différence d'ensembles : seuls
    les numéros réellement absents sont déclarés ABSENT (jamais de cascade de
    faux « ABSENT » sur les maillons présents)."""
    globaux: list[str] = []
    paths = sorted(p for p in Path(dossier).glob("[0-9][0-9][0-9][0-9].json"))
    presents = {int(p.stem) for p in paths}
    if presents:
        for absent in sorted(set(range(max(presents) + 1)) - presents):
            globaux.append(f"maillon {absent:04d} : ABSENT — trou de numérotation "
                           "(la Chaîne est séquentielle, sans trou)")
    return [_charger_fichier(p) for p in paths], globaux


# ---------------------------------------------------------------------------
# Les quatre passes
# ---------------------------------------------------------------------------

def passe_continuite(maillons: list[Maillon], globaux: list[str]) -> Passe:
    echecs = list(globaux)
    for m in maillons:
        echecs += m.erreurs
    ok = 0
    for i, m in enumerate(maillons):
        if m.data is None:
            continue
        if m.numero == 0:
            if m.data.get("precedent") != GENESE_PRECEDENT:
                echecs.append(f"maillon {m.path.stem} : precedent attendu "
                              f"{GENESE_PRECEDENT} (genèse), "
                              f"observé {m.data.get('precedent')!r}")
                continue
        else:
            precedent = maillons[i - 1] if i > 0 else None
            if precedent is None or precedent.numero != m.numero - 1:
                # Le vrai prédécesseur manque : la continuité de CE maillon est
                # invérifiable — on le dit, sans inventer de faux mismatch
                # contre un autre maillon.
                echecs.append(
                    f"maillon {m.path.stem} : prédécesseur {m.numero - 1:04d} "
                    "absent — continuité invérifiable pour ce maillon")
                continue
            attendu = sha256_bytes(precedent.brut)
            if m.data.get("precedent") != attendu:
                echecs.append(
                    f"maillon {m.path.stem} : precedent attendu {attendu} "
                    f"(sha256 des octets exacts de {precedent.nom}), "
                    f"observé {m.data.get('precedent')!r}")
                continue
        ok += 1
    if echecs:
        return Passe(1, "ÉCHEC", f"{ok}/{len(maillons)}", echecs, code=1)
    return Passe(1, "OK", f"{ok}/{len(maillons)}")


def passe_signatures(maillons: list[Maillon], cle: Path | None) -> Passe:
    n = len(maillons)
    echecs = []
    manquantes = [m for m in maillons
                  if not m.path.with_name(m.nom + ".minisig").exists()]
    for m in manquantes:
        echecs.append(f"maillon {m.path.stem} : fichier {m.nom}.minisig ABSENT")
    minisign = shutil.which("minisign")
    if cle is None:
        return Passe(2, "ÉCHEC" if echecs else "NON VÉRIFIÉ",
                     "clé publique introuvable — préciser --cle CHEMIN/cle-publique.pub",
                     echecs, code=2 if echecs else 0)
    if minisign is None:
        return Passe(2, "ÉCHEC" if echecs else "NON VÉRIFIÉ",
                     "binaire « minisign » introuvable — signatures non vérifiées "
                     "(installation : https://jedisct1.github.io/minisign/)",
                     echecs, code=2 if echecs else 0)
    ok = 0
    for m in maillons:
        sig = m.path.with_name(m.nom + ".minisig")
        if not sig.exists():
            continue
        r = subprocess.run(
            [minisign, "-V", "-q", "-p", str(cle), "-x", str(sig), "-m", str(m.path)],
            capture_output=True, text=True)
        if r.returncode == 0:
            ok += 1
        else:
            echecs.append(f"maillon {m.path.stem} : signature INVALIDE pour la clé "
                          f"{cle.name} ({(r.stderr or r.stdout).strip() or 'refus minisign'})")
    if echecs:
        return Passe(2, "ÉCHEC", f"{ok}/{n}, clé attendue : {cle.name}", echecs, code=2)
    return Passe(2, "OK", f"{ok}/{n}, clé attendue : {cle.name}")


def _classer_ots(sortie: str) -> str:
    """Classe la sortie d'« ots verify ». Le succès prime sur « pending » :
    le client interroge plusieurs calendriers et mélange couramment un
    « Pending » (calendrier en retard) avec le « Success! » d'un autre — la
    preuve est alors bel et bien confirmée. Une panne réseau relayée
    (« Bad Gateway »…) n'est jamais classée invalide : indéterminé, annoncé."""
    s = sortie.lower()
    if "success" in s or "attests" in s:
        return "confirme"
    if "pending" in s:
        return "attente"
    if "does not match" in s or "mismatch" in s or "invalid" in s:
        return "invalide"
    return "indetermine"


def passe_horodatages(maillons: list[Maillon], hors_ligne: bool) -> Passe:
    n = len(maillons)
    echecs = []
    presents = []
    for m in maillons:
        ots_path = m.path.with_name(m.nom + ".ots")
        if not ots_path.exists():
            echecs.append(f"maillon {m.path.stem} : fichier {m.nom}.ots ABSENT "
                          "(horodatage manquant)")
        elif not ots_path.read_bytes().startswith(OTS_MAGIC):
            echecs.append(f"maillon {m.path.stem} : {m.nom}.ots n'est pas une preuve "
                          "OpenTimestamps (en-tête invalide)")
        else:
            presents.append((m, ots_path))
    if hors_ligne:
        statut = "ÉCHEC" if echecs else "HORS-LIGNE"
        return Passe(3, statut,
                     f"{len(presents)}/{n} présents — vérification réseau SAUTÉE (hors-ligne)",
                     echecs, code=3 if echecs else 0)
    ots = shutil.which("ots")
    if ots is None:
        return Passe(3, "ÉCHEC" if echecs else "NON VÉRIFIÉ",
                     f"{len(presents)}/{n} présents — client « ots » introuvable "
                     "(pip install opentimestamps-client)",
                     echecs, code=3 if echecs else 0)
    confirmes, attente, indetermines = 0, 0, 0
    maintenant = datetime.now(timezone.utc)
    for m, ots_path in presents:
        try:
            r = subprocess.run([ots, "verify", "-f", str(m.path), str(ots_path)],
                               capture_output=True, text=True, timeout=60)
        except subprocess.TimeoutExpired:
            indetermines += 1
            continue
        etat = _classer_ots(r.stdout + r.stderr)
        if etat == "confirme":
            confirmes += 1
        elif etat == "attente":
            date, _ = _parse_date((m.data or {}).get("date"))
            if date is not None and maintenant - date > ATTENTE_MAX:
                echecs.append(f"maillon {m.path.stem} : horodatage en attente depuis "
                              f"plus de 7 j (daté {m.data.get('date')}) — "
                              "ancrage Bitcoin attendu, observé « pending »")
            else:
                attente += 1
        elif etat == "invalide":
            echecs.append(f"maillon {m.path.stem} : preuve OpenTimestamps INVALIDE "
                          f"({(r.stderr or r.stdout).strip()[:200]})")
        else:
            indetermines += 1
    morceaux = [f"{confirmes}/{n} confirmés Bitcoin"]
    if attente:
        morceaux.append(f"{attente} en attente < 7 j")
    if indetermines:
        morceaux.append(f"{indetermines} indéterminés (réseau/nœud indisponible — "
                        "non vérifiés)")
    if echecs:
        return Passe(3, "ÉCHEC", ", ".join(morceaux), echecs, code=3)
    statut = "OK" if indetermines == 0 else "NON VÉRIFIÉ"
    return Passe(3, statut, ", ".join(morceaux))


def passe_dates(maillons: list[Maillon]) -> Passe:
    echecs = []
    precedente = None
    for m in maillons:
        if m.data is None:
            continue
        date, erreur = _parse_date(m.data.get("date"))
        if erreur:
            echecs.append(f"maillon {m.path.stem} : {erreur}")
            continue
        if precedente is not None and date <= precedente[1]:
            echecs.append(
                f"maillon {m.path.stem} : date attendue strictement postérieure à "
                f"{precedente[1].isoformat()} (maillon {precedente[0]:04d}), "
                f"observé {m.data.get('date')}")
        precedente = (m.numero, date)
    if echecs:
        return Passe(4, "ÉCHEC", "rupture d'ordre", echecs, code=1)
    return Passe(4, "OK", "strictement croissantes")


# ---------------------------------------------------------------------------
# Points d'entrée de vérification
# ---------------------------------------------------------------------------

def verifier_tout(dossier: Path | str, hors_ligne: bool = False,
                  cle: Path | str | None = None) -> Rapport:
    dossier = Path(dossier)
    maillons, globaux = charger(dossier)
    cle = Path(cle) if cle else _trouver_cle(dossier)
    passes = [
        passe_continuite(maillons, globaux),
        passe_signatures(maillons, cle),
        passe_horodatages(maillons, hors_ligne),
        passe_dates(maillons),
    ]
    if not maillons:
        passes.insert(0, Passe(1, "ÉCHEC", f"aucun maillon dans {dossier}", code=1))
        del passes[1]
    return Rapport(maillons, passes, hors_ligne)


def verifier_maillon(dossier: Path | str, numero: int, hors_ligne: bool = False,
                     cle: Path | str | None = None) -> Rapport:
    """--maillon N (05 §4.6) : vérifie UN maillon — empreinte du précédent,
    signature, horodatage. Les défauts propres aux autres maillons ne sont pas
    imputés à N ; si le prédécesseur manque, la continuité est déclarée
    invérifiable (ÉCHEC explicite), jamais silencieusement sautée."""
    dossier = Path(dossier)
    cle = Path(cle) if cle else _trouver_cle(dossier)
    cible = _charger_fichier(dossier / f"{numero:04d}.json")

    echecs1 = list(cible.erreurs)
    precedent = None
    if numero == 0:
        if cible.data is not None and cible.data.get("precedent") != GENESE_PRECEDENT:
            echecs1.append(f"maillon {cible.path.stem} : precedent attendu "
                           f"{GENESE_PRECEDENT} (genèse), "
                           f"observé {cible.data.get('precedent')!r}")
    else:
        prev_path = dossier / f"{numero - 1:04d}.json"
        if not prev_path.exists():
            echecs1.append(f"maillon {cible.path.stem} : prédécesseur "
                           f"{numero - 1:04d} introuvable — continuité invérifiable")
        else:
            precedent = _charger_fichier(prev_path)
            attendu = sha256_bytes(precedent.brut)
            if cible.data is not None and cible.data.get("precedent") != attendu:
                echecs1.append(
                    f"maillon {cible.path.stem} : precedent attendu {attendu} "
                    f"(sha256 des octets exacts de {precedent.nom}), "
                    f"observé {cible.data.get('precedent')!r}")
    p1 = (Passe(1, "ÉCHEC", "0/1", echecs1, code=1) if echecs1
          else Passe(1, "OK", "1/1"))

    p2 = passe_signatures([cible], cle)
    p3 = passe_horodatages([cible], hors_ligne)

    echecs4 = []
    detail4 = "date valide"
    date, erreur = _parse_date((cible.data or {}).get("date"))
    if erreur:
        echecs4.append(f"maillon {cible.path.stem} : {erreur}")
    elif precedent is not None and precedent.data is not None:
        date_prev, erreur_prev = _parse_date(precedent.data.get("date"))
        if erreur_prev is None:
            if date <= date_prev:
                echecs4.append(
                    f"maillon {cible.path.stem} : date attendue strictement "
                    f"postérieure à {precedent.data.get('date')} (maillon "
                    f"{precedent.numero:04d}), observé {cible.data.get('date')}")
            else:
                detail4 = f"postérieure au maillon {precedent.numero:04d}"
    elif numero > 0:
        p4 = Passe(4, "NON VÉRIFIÉ",
                   "prédécesseur absent — ordre des dates invérifiable")
        return Rapport([cible], [p1, p2, p3, p4], hors_ligne, partiel=numero)
    p4 = (Passe(4, "ÉCHEC", "rupture d'ordre", echecs4, code=1) if echecs4
          else Passe(4, "OK", detail4))
    return Rapport([cible], [p1, p2, p3, p4], hors_ligne, partiel=numero)


def verifier_fichier(dossier: Path | str, fichier: Path | str,
                     numero: int) -> tuple[int, str]:
    """Authentifie un fichier téléchargé contre les empreintes du maillon N."""
    dossier, fichier = Path(dossier), Path(fichier)
    path = dossier / f"{numero:04d}.json"
    if not path.exists():
        return 1, f"ÉCHEC : maillon {numero:04d} introuvable dans {dossier}"
    if not fichier.exists():
        return 4, f"erreur d'usage : fichier introuvable : {fichier}"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except ValueError as exc:
        return 1, f"ÉCHEC : maillon {numero:04d} illisible ({exc})"
    h = hashlib.sha256()
    with open(fichier, "rb") as f:
        for bloc in iter(lambda: f.read(1 << 20), b""):
            h.update(bloc)
    observe = h.hexdigest()
    empreintes = data.get("empreintes") or []
    for e in empreintes:
        if e.get("sha256") == observe:
            return 0, (f"OK : « {fichier.name} » correspond à l'empreinte "
                       f"« {e.get('objet')} » du maillon {numero:04d} "
                       f"(sha256 {_abrege(observe)})")
    attendus = ", ".join(f"{e.get('objet')} = {_abrege(str(e.get('sha256')))}"
                         for e in empreintes) or "aucune empreinte au maillon"
    return 1, (f"ÉCHEC : « {fichier.name} » ne correspond à aucune empreinte du "
               f"maillon {numero:04d} — attendu : {attendus} ; "
               f"observé : {observe}")


def _trouver_cle(dossier: Path) -> Path | None:
    for candidat in (dossier / "cle-publique.pub",
                     dossier.parent / "cle-publique.pub",
                     Path.cwd() / "cle-publique.pub",
                     Path(__file__).resolve().parent / "cle-publique.pub"):
        if candidat.exists():
            return candidat
    return None


def _trouver_dossier() -> Path | None:
    """Sans --dossier : disposition du zip (./chaine), puis celle du repo."""
    for candidat in (Path.cwd() / "chaine",
                     Path(__file__).resolve().parent / "journal",
                     Path.cwd()):
        if candidat.is_dir() and any(candidat.glob("[0-9][0-9][0-9][0-9].json")):
            return candidat
    return None


# ---------------------------------------------------------------------------
# Interface en ligne de commande
# ---------------------------------------------------------------------------

class _Parser(argparse.ArgumentParser):
    def error(self, message):  # erreur d'usage → code 4 (05 §4.6)
        print(f"erreur d'usage : {message}", file=sys.stderr)
        # Ligne d'usage canonique (05 §4.6), complétée des options locales.
        print("usage : verifie.py [--tout] [--maillon N] "
              "[--fichier CHEMIN --maillon N] [--json] [--hors-ligne]",
              file=sys.stderr)
        print("         (options locales : --dossier D, --cle PUB)", file=sys.stderr)
        raise SystemExit(4)


def main(argv=None) -> int:
    ap = _Parser(prog="verifie.py",
                 description="Vérificateur public de la Chaîne (CHAINE/1) — "
                             "https://intranautes.ai/verifier")
    ap.add_argument("--tout", action="store_true",
                    help="vérifier la chaîne entière (défaut)")
    ap.add_argument("--maillon", type=int, metavar="N",
                    help="vérifier un seul maillon")
    ap.add_argument("--fichier", metavar="CHEMIN",
                    help="authentifier un fichier téléchargé contre le maillon N "
                         "(exige --maillon)")
    ap.add_argument("--json", action="store_true", help="sortie machine")
    ap.add_argument("--hors-ligne", action="store_true", dest="hors_ligne",
                    help="sauter les vérifications réseau (annoncé dans la sortie)")
    ap.add_argument("--dossier", metavar="D",
                    help="dossier des maillons (défaut : ./chaine puis ./journal)")
    ap.add_argument("--cle", metavar="PUB",
                    help="clé publique minisign attendue (défaut : cle-publique.pub "
                         "à côté des maillons)")
    args = ap.parse_args(argv)

    if args.fichier and args.maillon is None:
        ap.error("--fichier exige --maillon N")

    dossier = Path(args.dossier) if args.dossier else _trouver_dossier()
    if dossier is None or not dossier.is_dir():
        ap.error("aucun dossier de maillons trouvé — préciser --dossier")

    if args.fichier:
        code, message = verifier_fichier(dossier, args.fichier, args.maillon)
        if code == 4:
            ap.error(message)
        print(json.dumps({"code": code, "message": message}, ensure_ascii=False)
              if args.json else message)
        return code

    if args.maillon is not None:
        if not (dossier / f"{args.maillon:04d}.json").exists():
            existants = sorted(dossier.glob("[0-9][0-9][0-9][0-9].json"))
            tete = existants[-1].stem if existants else "aucun maillon"
            ap.error(f"maillon {args.maillon:04d} inexistant "
                     f"(dernier maillon présent : {tete})")
        rapport = verifier_maillon(dossier, args.maillon,
                                   hors_ligne=args.hors_ligne, cle=args.cle)
    else:
        rapport = verifier_tout(dossier, hors_ligne=args.hors_ligne, cle=args.cle)
    print(json.dumps(rapport.as_json(), ensure_ascii=False, indent=2)
          if args.json else rapport.rendu())
    return rapport.code


if __name__ == "__main__":
    sys.exit(main())
