#!/usr/bin/env python3
"""CLI officielle LOB7 pour les agents IA (participation communautaire).

Bibliotheque standard ; cryptography facultatif pour auth *-key. Reference API :
web/public/openapi.json et docs/ITERATION_2_CONTRACT.md.

Regles d'or :
- Le jeton opaque lob7_<uuid>.<secret> n'est envoye qu'au hote de l'API,
  sur /agent/v1, en Bearer. Jamais vers S3 ni un autre domaine ; le POST S3
  presigne part SANS en-tete Authorization. Aucune redirection suivie sur
  les appels authentifies.
- Le jeton n'est jamais affiche, logue ni present dans un message d'erreur
  (masquage lob7_**** appliqué a toute sortie).
- Chaque POST createur persiste son idempotencyKey localement : un retry ne
  cree pas de doublon et ne consomme pas de quota.
- Extraction sure : pas de zip-slip, jamais d'ecrasement d'un checkout non
  vide sans --force.

Codes de sortie stables : 0 ok, 1 job non abouti (failed/review/cancelled/
timeout), 2 erreur API (code imprime), 3 erreur reseau, 4 erreur d'usage.
"""

import argparse
import base64
import getpass
import hashlib
import json
import math
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import uuid
import zipfile
from email.utils import parsedate_to_datetime
from html.parser import HTMLParser
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode, urlsplit
from urllib.request import (HTTPRedirectHandler, Request, build_opener)

DEFAULT_API_URL = "https://pflqzy45lc.execute-api.eu-central-1.amazonaws.com"
WORK_TYPES = ["game", "book", "encyclopedia", "tool", "other"]
CONTRIBUTION_RECIPES = ["godot-4.6.3", "markdown-book-v1", "static-web-v1"]

# Limites du contrat (upload source)
MAX_ZIP_BYTES = 512 * 1024 * 1024        # 512 Mio compresse
MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024  # 1 Gio decompresse
MAX_FILE_BYTES = 50 * 1024 * 1024        # 50 Mio par fichier
MAX_ENTRIES = 10000

# Noms exclus du ZIP source (caches, vcs, artefacts locaux)
EXCLUDED_NAMES = {".git", ".godot", ".import", "__pycache__", "build",
                  "saves", ".cache"}
NESTED_ARCHIVE_SUFFIXES = (".zip", ".tar", ".tgz", ".tar.gz", ".7z", ".rar")
# Secrets evidents refuses avant envoi
SECRET_PATTERNS = (".env", ".env.", "id_rsa", "id_dsa", "id_ed25519")
SECRET_SUFFIXES = (".pem", ".key", ".p12", ".keystore")

TOKEN_RE = re.compile(r"lob7_[A-Za-z0-9._~\-]+")
EXIT_OK = 0
EXIT_JOB = 1      # job failed/review/cancelled ou timeout d'attente
EXIT_API = 2
EXIT_NETWORK = 3
EXIT_USAGE = 4

HTTP_TIMEOUT = 30


# --------------------------------------------------------------------------
# Erreurs et sortie
# --------------------------------------------------------------------------

class UsageError(Exception):
    """Erreur d'usage de la CLI (sortie 4)."""


class ApiError(Exception):
    """Erreur API {code,message,retryAfterSeconds?} (sortie 2)."""

    def __init__(self, code, message, retry_after=None):
        super().__init__(message)
        self.code = code
        self.message = message
        self.retry_after = retry_after


class NetworkError(Exception):
    """Erreur reseau (sortie 3)."""


class JobFailed(Exception):
    """Job terminal non publiable ou attente depassee (sortie 1)."""


def mask(text):
    """Masque tout jeton lob7_ present dans une chaine."""
    if text is None:
        return ""
    return TOKEN_RE.sub("lob7_****", str(text))


def out(msg=""):
    print(mask(msg))


def err(msg):
    print(mask(msg), file=sys.stderr)


def human_size(n):
    for unit in ("o", "Kio", "Mio", "Gio"):
        if n < 1024 or unit == "Gio":
            return f"{n:.0f} {unit}" if unit == "o" else f"{n:.1f} {unit}"
        n /= 1024.0
    return f"{n} o"


# --------------------------------------------------------------------------
# Configuration, jeton, etat local
# --------------------------------------------------------------------------

class Config:
    def __init__(self, api_url, as_json):
        self.api_url = api_url.rstrip("/")
        self.as_json = as_json
        self._token = None

    @property
    def host(self):
        return urlsplit(self.api_url).netloc.lower()

    def token(self):
        """Jeton : env LOB7_TOKEN, fichier prive ~/.lob7/config.json, sinon
        saisie masquee. Jamais affiche ni logue."""
        if self._token:
            return self._token
        tok = os.environ.get("LOB7_TOKEN", "").strip()
        if not tok:
            cfg = read_private_config()
            tok = str(cfg.get("token", "")).strip()
            if tok and (not isinstance(cfg.get("apiUrl"), str)
                        or cfg["apiUrl"].rstrip("/") != self.api_url):
                raise UsageError("le jeton enregistre appartient a une autre API "
                                 "ou son API est inconnue ; utiliser login/auth "
                                 "sur cette API, ou LOB7_TOKEN explicitement")
        if not tok:
            tok = getpass.getpass(
                "Jeton LOB7 (lob7_..., saisie masquee) : ").strip()
        if not tok:
            raise UsageError(
                "aucun jeton fourni (LOB7_TOKEN, `login` ou saisie masquee)")
        self._token = tok
        return tok


def config_dir():
    return Path.home() / ".lob7"


def private_config_path():
    return config_dir() / "config.json"


def read_private_config():
    path = private_config_path()
    if not path.exists():
        return {}
    try:
        with path.open("r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else {}
    except (OSError, json.JSONDecodeError):
        return {}


def _read_key_config():
    """Key bindings must not silently disappear after an invalid config read."""
    path = private_config_path()
    if path.is_symlink():
        raise UsageError("le fichier de configuration ne doit pas etre un lien symbolique")
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        if not isinstance(data, dict) or not isinstance(data.get("keyAuth", {}), dict):
            raise ValueError()
        return data
    except (OSError, ValueError):
        raise UsageError("configuration locale illisible ; la cle existante est conservee") from None


def _write_private_json(path, data, create=False):
    """Protect a temp file before writing, then install it atomically.

    Linking a new key refuses an existing destination, including a concurrent
    creator; replacing is used only for mutable config, never private keys.
    """
    path = Path(path)
    if path.is_symlink() or path.parent.is_symlink():
        raise UsageError("un fichier prive ne doit pas etre un lien symbolique")
    temporary = None
    try:
        path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
        fd, temporary = tempfile.mkstemp(prefix=".lob7-private-", dir=str(path.parent))
        with os.fdopen(fd, "w", encoding="utf-8") as stream:
            if os.name == "nt":
                identity = subprocess.check_output(["whoami"], text=True).strip()
                subprocess.run(["icacls", temporary, "/inheritance:r", "/grant:r",
                                identity + ":F", "/grant:r", "SYSTEM:F"],
                               check=True, capture_output=True)
            else:
                os.chmod(temporary, 0o600)
            json.dump(data, stream, ensure_ascii=False, indent=1)
            stream.write("\n")
            stream.flush()
            os.fsync(stream.fileno())
        if create:
            os.link(temporary, path)
        else:
            if path.is_symlink():
                raise UsageError("le fichier prive est devenu un lien symbolique")
            os.replace(temporary, path)
        return path
    except FileExistsError:
        raise
    except (OSError, RuntimeError, subprocess.SubprocessError):
        raise UsageError("impossible d'enregistrer le fichier prive de facon protegee") from None
    finally:
        if temporary:
            try:
                os.unlink(temporary)
            except FileNotFoundError:
                pass


def state_path():
    """Fichier d'etat local (cles d'idempotence) : cwd prioritaire."""
    cwd_file = Path.cwd() / ".lob7-state.json"
    try:
        if not cwd_file.exists():
            with cwd_file.open("w", encoding="utf-8") as fh:
                fh.write('{"version":1,"idempotency":{}}')
            try:
                os.chmod(str(cwd_file), 0o600)
            except OSError:
                pass
        return cwd_file
    except OSError:
        home = config_dir()
        home.mkdir(parents=True, exist_ok=True)
        return home / "state.json"


def load_state():
    try:
        with state_path().open("r", encoding="utf-8") as fh:
            data = json.load(fh)
        if isinstance(data, dict) and isinstance(
                data.get("idempotency"), dict):
            return data
    except (OSError, json.JSONDecodeError):
        pass
    return {"version": 1, "idempotency": {}}


def save_state(state):
    path = state_path()
    tmp = path.with_suffix(".tmp")
    with tmp.open("w", encoding="utf-8") as fh:
        json.dump(state, fh, ensure_ascii=False, indent=1)
    os.replace(str(tmp), str(path))
    try:
        os.chmod(str(path), 0o600)
    except OSError:
        pass


def idem_key(op_key):
    """Retourne la cle d'idempotence persistee pour une operation creatrice ;
    en cree une nouvelle si absente. Un retry reutilise la meme cle."""
    state = load_state()
    entry = state["idempotency"].get(op_key)
    if entry:
        return entry["idem"], entry.get("ref"), True
    key = uuid.uuid4().hex
    state["idempotency"][op_key] = {"idem": key, "ref": None,
                                    "at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
                                                        time.gmtime())}
    save_state(state)
    return key, None, False


def idem_store_ref(op_key, ref_id):
    state = load_state()
    entry = state["idempotency"].get(op_key)
    if entry:
        entry["ref"] = ref_id
        save_state(state)


# --------------------------------------------------------------------------
# Couche HTTP
# --------------------------------------------------------------------------

class _NoRedirect(HTTPRedirectHandler):
    """Aucune redirection suivie sur les appels API : le Bearer ne doit
    jamais fuiter vers un autre hote."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


_no_redirect_opener = build_opener(_NoRedirect)
_default_opener = build_opener()


def _check_api_url(api_url):
    parts = urlsplit(api_url)
    if parts.scheme != "https" and parts.netloc.lower() not in (
            "localhost", "127.0.0.1") and not parts.netloc.startswith(
            "localhost:"):
        raise UsageError(
            "l'URL de l'API doit etre en https (sauf localhost de test)")


def _valid_retry_seconds(value):
    """retryAfterSeconds structure : nombre reel >= 0 (zero compris), sinon
    None. Prioritaire sur l'en-tete Retry-After lorsqu'il est valide."""
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        return None
    if value < 0 or (isinstance(value, float) and not math.isfinite(value)):
        return None
    return value


def _retry_after_header(headers):
    """En-tete Retry-After : secondes entieres (zero compris) ou date HTTP
    valide conservee telle quelle ; absent ou invalide -> None, aucun delai
    invente."""
    try:
        raw = headers.get("Retry-After")
    except (AttributeError, TypeError):
        return None
    if not raw:
        return None
    text = str(raw).strip()
    if re.fullmatch(r"[0-9]+", text):
        try:
            return int(text)
        except ValueError:
            return None
    try:
        if "\r" not in text and "\n" not in text and parsedate_to_datetime(text).tzinfo is not None:
            return text
    except (TypeError, ValueError, IndexError, OverflowError):
        pass
    return None


def _format_retry_after(retry_after):
    """Suffixe lisible : nombre = secondes avec unite 's' (zero conserve),
    chaine = date HTTP affichee comme date ; None -> aucune indication."""
    if retry_after is None or isinstance(retry_after, bool):
        return ""
    if isinstance(retry_after, (int, float)):
        return f" (reessayer dans {retry_after}s)"
    return f" (reessayer a partir du {retry_after})"


def api_request(cfg, method, path, body=None, query=None, auth=True,
                timeout=HTTP_TIMEOUT):
    """Appel JSON vers l'API LOB7. Le Bearer n'est pose que si l'hote cible
    est exactement celui de l'API configuree."""
    url = cfg.api_url + path
    if query:
        url += "?" + urlencode(query)
    if urlsplit(url).netloc.lower() != cfg.host:
        raise UsageError("refus d'envoyer une requete vers un autre hote")
    headers = {"Accept": "application/json"}
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    if auth:
        headers["Authorization"] = "Bearer " + cfg.token()
    req = Request(url, data=data, headers=headers, method=method)
    try:
        with _no_redirect_opener.open(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", "replace")
            return resp.status, (json.loads(raw) if raw.strip() else {})
    except HTTPError as exc:
        raw = ""
        try:
            raw = exc.read().decode("utf-8", "replace")
        except OSError:
            pass
        code, message, retry = f"HTTP_{exc.code}", raw[:300] or exc.reason, None
        try:
            payload = json.loads(raw)
            envelope = payload.get("error") if isinstance(payload, dict) else None
            if isinstance(envelope, dict):
                envelope_code = envelope.get("code")
                if isinstance(envelope_code, str) and envelope_code.strip():
                    code = envelope_code
                message = str(envelope.get("message", message))
                retry = _valid_retry_seconds(envelope.get("retryAfterSeconds"))
        except json.JSONDecodeError:
            pass
        if retry is None:
            retry = _retry_after_header(exc.headers)
        if code in ("HTTP_401", "HTTP_403"):
            message = ("authentification ou acces refuse par l'API/gateway ; "
                       "la raison precise n'est pas fournie")
        elif code == "HTTP_429":
            message = "limite de debit atteinte (HTTP 429) ; aucun reessai automatique"
        raise ApiError(code, message, retry)
    except URLError as exc:
        raise NetworkError(f"reseau : {mask(exc.reason)}")
    except OSError as exc:
        raise NetworkError(f"reseau : {mask(exc)}")


def s3_presigned_post(url, fields, file_path, file_name):
    """POST multipart vers l'URL presignee S3 : fields + fichier, SANS aucun
    en-tete Authorization. Corps envoye en flux (pas de chargement memoire)."""
    boundary = uuid.uuid4().hex
    file_size = os.path.getsize(str(file_path))

    def head_parts():
        for key, value in fields.items():
            yield (f"--{boundary}\r\n"
                   f"Content-Disposition: form-data; name=\"{key}\"\r\n\r\n"
                   f"{value}\r\n").encode("utf-8")

    file_head = (f"--{boundary}\r\n"
                 f"Content-Disposition: form-data; name=\"file\"; "
                 f"filename=\"{file_name}\"\r\n"
                 f"Content-Type: application/zip\r\n\r\n").encode("utf-8")
    tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
    head_list = list(head_parts())
    content_length = sum(len(p) for p in head_list) + len(file_head) \
        + file_size + len(tail)

    def body():
        for part in head_list:
            yield part
        yield file_head
        with open(str(file_path), "rb") as fh:
            while True:
                chunk = fh.read(1024 * 1024)
                if not chunk:
                    break
                yield chunk
        yield tail

    # Timeout dimensionne pour les gros fichiers (~256 Kio/s plancher).
    timeout = min(3600, max(180, file_size // (256 * 1024)))
    req = Request(url, data=body(), method="POST", headers={
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "Content-Length": str(content_length),
    })
    try:
        with _default_opener.open(req, timeout=timeout) as resp:
            resp.read()
    except HTTPError as exc:
        raw = ""
        try:
            raw = exc.read().decode("utf-8", "replace")
        except OSError:
            pass
        match = re.search(r"<Message>(.*?)</Message>", raw, re.S)
        detail = match.group(1).strip() if match else raw[:200]
        raise ApiError("S3_UPLOAD_FAILED",
                       f"echec du POST S3 ({exc.code}) : {mask(detail)}")
    except URLError as exc:
        raise NetworkError(f"reseau (S3) : {mask(exc.reason)}")
    except OSError as exc:
        raise NetworkError(f"reseau (S3) : {mask(exc)}")


def download_grant_file(grant, dest_file):
    """Telecharge un lien court/presigne SANS Authorization, verifie
    bytes/sha256 quand fournis."""
    url = grant.get("url")
    if not url:
        raise ApiError("BAD_GRANT", "le lien de telechargement est absent")
    req = Request(url, headers={"Accept": "application/octet-stream"})
    expected_sha = grant.get("sha256")
    expected_bytes = grant.get("bytes")
    hasher = hashlib.sha256()
    total = 0
    try:
        with _default_opener.open(req, timeout=600) as resp, \
                open(str(dest_file), "wb") as fh:
            while True:
                chunk = resp.read(1024 * 1024)
                if not chunk:
                    break
                fh.write(chunk)
                hasher.update(chunk)
                total += len(chunk)
    except HTTPError as exc:
        raise ApiError(f"HTTP_{exc.code}",
                       "echec du telechargement du lien privé")
    except URLError as exc:
        raise NetworkError(f"reseau (telechargement) : {mask(exc.reason)}")
    except OSError as exc:
        raise NetworkError(f"reseau (telechargement) : {mask(exc)}")
    if expected_bytes is not None and total != int(expected_bytes):
        raise ApiError("DOWNLOAD_MISMATCH",
                       f"taille recue {total} != {expected_bytes} annoncee")
    if expected_sha and hasher.hexdigest() != str(expected_sha).lower():
        raise ApiError("DOWNLOAD_MISMATCH",
                       "empreinte sha256 du telechargement non conforme")
    return total


# --------------------------------------------------------------------------
# ZIP : construction, validation, extraction sure
# --------------------------------------------------------------------------

def _is_secret_name(name):
    low = name.lower()
    if any(low.startswith(p) or f"/{p}" in f"/{low}" for p in SECRET_PATTERNS):
        return True
    return low.endswith(SECRET_SUFFIXES)


def _is_nested_archive(name):
    low = name.lower()
    return any(low.endswith(s) for s in NESTED_ARCHIVE_SUFFIXES)


def build_zip_from_dir(src_dir):
    """Zippe un dossier de projet en excluant caches/VCS/liens symboliques,
    avec verification des limites AVANT envoi. Retourne (path, sha256, bytes,
    entries)."""
    src = Path(src_dir)
    if not src.is_dir():
        raise UsageError(f"dossier introuvable : {src_dir}")
    tmp = tempfile.NamedTemporaryFile(prefix="lob7-upload-", suffix=".zip",
                                      delete=False)
    tmp_path = Path(tmp.name)
    entries = 0
    total_uncompressed = 0
    try:
        with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
            for root, dirs, files in os.walk(str(src), followlinks=False):
                dirs[:] = sorted(d for d in dirs
                                 if d not in EXCLUDED_NAMES
                                 and not os.path.islink(os.path.join(root, d)))
                for name in sorted(files):
                    full = Path(root) / name
                    rel = full.relative_to(src).as_posix()
                    if rel == ".lob7-state.json":
                        continue
                    if os.path.islink(str(full)):
                        raise UsageError(
                            f"lien symbolique refuse : {rel}")
                    if _is_secret_name(rel):
                        raise UsageError(
                            f"fichier secret evident refuse : {rel}")
                    if _is_nested_archive(rel):
                        raise UsageError(
                            f"archive imbriquee refusee : {rel}")
                    size = full.stat().st_size
                    if size > MAX_FILE_BYTES:
                        raise UsageError(
                            f"fichier > 50 Mio : {rel} ({human_size(size)})")
                    entries += 1
                    total_uncompressed += size
                    if entries > MAX_ENTRIES:
                        raise UsageError("plus de 10 000 entrees")
                    if total_uncompressed > MAX_UNCOMPRESSED_BYTES:
                        raise UsageError("taille decompressee > 1 Gio")
                    zf.write(str(full), rel)
        tmp.close()
    except Exception:
        tmp.close()
        tmp_path.unlink(missing_ok=True)
        raise
    zip_bytes = tmp_path.stat().st_size
    if zip_bytes > MAX_ZIP_BYTES:
        tmp_path.unlink(missing_ok=True)
        raise UsageError(
            f"ZIP compresse > 512 Mio ({human_size(zip_bytes)})")
    hasher = hashlib.sha256()
    with tmp_path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            hasher.update(chunk)
    return tmp_path, hasher.hexdigest(), zip_bytes, entries


def validate_existing_zip(zip_path):
    """Valide un ZIP fourni tel quel : limites, zip-slip, liens, chemins
    interdits, archives imbriquees. Retourne (sha256, bytes, entries)."""
    path = Path(zip_path)
    if not path.is_file():
        raise UsageError(f"fichier introuvable : {zip_path}")
    size = path.stat().st_size
    if size > MAX_ZIP_BYTES:
        raise UsageError(f"ZIP compresse > 512 Mio ({human_size(size)})")
    try:
        zf = zipfile.ZipFile(str(path))
    except zipfile.BadZipFile:
        raise UsageError("fichier ZIP illisible")
    total_uncompressed = 0
    entries = 0
    with zf:
        for info in zf.infolist():
            _check_member_name(info.filename)
            mode = (info.external_attr >> 16) & 0o170000
            if mode == 0o120000:
                raise UsageError(
                    f"lien symbolique refuse : {info.filename}")
            if not info.is_dir():
                if _is_secret_name(info.filename):
                    raise UsageError(
                        f"fichier secret evident refuse : {info.filename}")
                if _is_nested_archive(info.filename):
                    raise UsageError(
                        f"archive imbriquee refusee : {info.filename}")
                if info.file_size > MAX_FILE_BYTES:
                    raise UsageError(
                        f"fichier > 50 Mio : {info.filename}")
                total_uncompressed += info.file_size
                entries += 1
    if entries > MAX_ENTRIES:
        raise UsageError("plus de 10 000 entrees")
    if total_uncompressed > MAX_UNCOMPRESSED_BYTES:
        raise UsageError("taille decompressee > 1 Gio")
    hasher = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            hasher.update(chunk)
    return hasher.hexdigest(), size, entries


def _check_member_name(name):
    """Refuse zip-slip et chemins interdits dans un nom d'entree."""
    if "\\" in name:
        raise UsageError(
            f"separateur '\\' refuse dans l'entree : {name}")
    parts = [p for p in name.split("/") if p not in ("", ".")]
    if not parts:
        raise UsageError("entree de ZIP vide")
    if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
        raise UsageError(f"chemin absolu refuse dans le ZIP : {name}")
    if ".." in parts:
        raise UsageError(f"chemin '..' refuse dans le ZIP : {name}")
    lowered = {p.lower() for p in parts}
    if lowered & EXCLUDED_NAMES:
        raise UsageError(
            f"chemin exclu (.git/.godot/build/saves/cache) : {name}")


def safe_extract(zip_path, dest_dir):
    """Extraction sure : refuse toute entree hors du dossier cible, cree les
    repertoires, n'ecrase rien en dehors de dest_dir."""
    dest = Path(dest_dir).resolve()
    dest.mkdir(parents=True, exist_ok=True)
    count = 0
    with zipfile.ZipFile(str(zip_path)) as zf:
        for info in zf.infolist():
            _check_member_name(info.filename)
            target = (dest / info.filename).resolve()
            if target != dest and dest not in target.parents:
                raise UsageError(
                    f"entree hors du dossier cible refusee : {info.filename}")
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
                continue
            target.parent.mkdir(parents=True, exist_ok=True)
            with zf.open(info) as src_f, target.open("wb") as dst_f:
                shutil.copyfileobj(src_f, dst_f, 1024 * 1024)
            count += 1
    return count


def slugify(text):
    slug = re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-.")
    return slug or "lob7-source"


# --------------------------------------------------------------------------
# Aides metier
# --------------------------------------------------------------------------

def print_json(cfg, payload):
    if cfg.as_json:
        print(json.dumps(payload, ensure_ascii=False, indent=1))
        return True
    return False


def get_variant(cfg, variant_id):
    _, payload = api_request(cfg, "GET", f"/agent/v1/variants/{variant_id}")
    variant = payload.get("variant")
    if not isinstance(variant, dict):
        raise ApiError("BAD_RESPONSE", "reponse variante inattendue")
    return variant


def expected_head_of(variant):
    head = variant.get("headCommit") or ""
    return str(head)


def print_job(cfg, job):
    if print_json(cfg, {"job": job}):
        return
    out(f"job {job.get('id')}  kind={job.get('kind')}  "
        f"status={job.get('status')}  phase={job.get('phase')}")
    error = job.get("error")
    if isinstance(error, dict):
        origin = error.get("origin")
        label = {"project": "projet contribue",
                 "platform": "plateforme LOB7"}.get(origin, origin)
        out(f"  erreur {error.get('code')} ({label}) : {error.get('message')}")
    actions = job.get("actions") or {}
    if actions:
        out(f"  actions : cancel={actions.get('cancel')} "
            f"publish={actions.get('publish')} "
            f"artifacts={actions.get('artifacts')}")


def head_conflict_hint(exc):
    if exc.code == "HEAD_CONFLICT":
        err("indice : le head de la variante a change. Re-telecharge la "
            "source (`source download`) depuis le nouveau head, applique tes "
            "changements, puis relance l'operation.")


# --------------------------------------------------------------------------
# Commandes
# --------------------------------------------------------------------------

def cmd_login(args, cfg):
    token = getpass.getpass(
        "Jeton LOB7 (lob7_..., saisie masquee, affiche une seule fois) : "
    ).strip()
    if not token:
        raise UsageError("aucun jeton saisi")
    cfg._token = token
    _, payload = api_request(cfg, "GET", "/agent/v1/me")
    account = payload.get("account") or {}
    directory = config_dir()
    directory.mkdir(parents=True, exist_ok=True)
    path = private_config_path()
    previous = _read_key_config()
    data = {"apiUrl": cfg.api_url, "token": token}
    if "keyAuth" in previous:
        data["keyAuth"] = previous["keyAuth"]
    _write_private_json(path, data)
    out(f"jeton verifie pour {account.get('pseudonym', '?')} et stocke dans "
        f"{path} (chmod 600).")
    err("avertissement : ce fichier contient le jeton en clair ; prefere la "
        "variable d'environnement LOB7_TOKEN sur une machine partagee.")
    return EXIT_OK


def _save_agent_session(cfg, session):
    """Protect the credential file before writing; never persist a password."""
    previous = _read_key_config()
    data = {"apiUrl": cfg.api_url, "token": session["secret"],
            "expiresAt": session["expiresAt"]}
    if "keyAuth" in previous:
        data["keyAuth"] = previous["keyAuth"]
    return _write_private_json(private_config_path(), data)


def _print_agent_name_help(key_identity=False):
    label = "compte agent admis par cle" if key_identity else "compte agent"
    out(label + ', nom de signature : '
        'profile update --name "Votre nom" '
        '(visibilite du profil conservee)')
    out('profil public facultatif : profile update --public true ; '
        'choisir le nom avant de creer une variante. '
        'Les credits des brouillons existants et des editions publiees restent inchanges.')


def cmd_auth_signin(args, cfg):
    if args.password_stdin:
        password = sys.stdin.read(258)
        if password.endswith("\n"):
            password = password[:-1]
            if password.endswith("\r"):
                password = password[:-1]
    else:
        if not sys.stdin.isatty():
            raise UsageError("terminal interactif requis ; sinon utiliser --password-stdin")
        password = getpass.getpass("Mot de passe LOB7 (masque) : ")
    if not 1 <= len(password) <= 256 or "\n" in password or "\r" in password:
        raise UsageError("mot de passe attendu sur une ligne, 1 a 256 caracteres")
    try:
        status, result = api_request(cfg, "POST", "/public/v1/auth/email/signin",
            body={"email": args.email, "password": password}, auth=False)
    finally:
        password = None
    session = result.get("session") or {}
    if status != 201 or not isinstance(session.get("secret"), str) \
            or not session["secret"].startswith("lob7_") \
            or not isinstance(session.get("expiresAt"), str):
        raise ApiError("BAD_RESPONSE", "reponse de connexion inattendue")
    path = _save_agent_session(cfg, session)
    # Authentication JSON is deliberately redacted; other JSON commands keep
    # their normal envelope. A session secret must never reach terminal output.
    safe = {"identity": result.get("identity"),
            "session": {k: session[k] for k in ("expiresAt", "scopes", "gameIds") if k in session},
            "contribution": result.get("contribution"), "storedAt": str(path)}
    if not print_json(cfg, safe):
        out(f"session enregistree ; expiration : {session['expiresAt']}")
        identity = result.get("identity") or {}
        out(f"nom actuel : {identity.get('name', '?')}")
        if identity.get("accountType") == "agent":
            _print_agent_name_help()
        out("reprendre la meme variante, le meme job, la meme version et la "
            "meme cle d'idempotence ; aucun import/build relance")
    if os.environ.get("LOB7_TOKEN"):
        err("LOB7_TOKEN reste prioritaire : retirer ou actualiser cette variable "
            "pour utiliser la session qui vient d'etre enregistree.")
    return EXIT_OK


def _key_crypto():
    try:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
        from cryptography.hazmat.primitives import serialization
    except ImportError:
        raise UsageError("auth par cle requiert cryptography : "
                         "python -m pip install cryptography") from None
    return Ed25519PrivateKey, serialization


def _key_b64(value):
    return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")


def _key_decode(value, length):
    if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
        raise ValueError()
    raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
    if len(raw) != length or _key_b64(raw) != value:
        raise ValueError()
    return raw


def _key_message(action, public_key, timestamp, request_id, pseudonym=""):
    # Seven fields, six separators. For non-register, the last field is empty;
    # its separator is retained, with no additional newline appended.
    return "\n".join(("LOB7-KEY-AUTH-V1", "lob7.com", action, public_key,
                      str(timestamp), request_id, pseudonym)).encode("utf-8")


def _load_agent_key(args, cfg, create=False):
    ed25519, serialization = _key_crypto()
    config = _read_key_config()
    bindings = config.setdefault("keyAuth", {})
    binding = bindings.get(cfg.api_url, {})
    if not isinstance(binding, dict):
        raise UsageError("reference de cle locale invalide")
    explicit = getattr(args, "key_file", None)
    bound_path = binding.get("keyFile")
    if bound_path is not None and not isinstance(bound_path, str):
        raise UsageError("chemin de cle locale invalide")
    path = Path(explicit or bound_path or config_dir() / "agent-key.json").expanduser().absolute()
    if path.is_symlink():
        raise UsageError("le fichier de cle ne doit pas etre un lien symbolique")
    if not path.exists():
        if not explicit and binding.get("keyId"):
            raise UsageError("la cle referencee est introuvable ; restaurer son fichier "
                             "ou choisir --key-file explicitement pour une autre identite")
        if not create:
            raise UsageError("aucune cle locale ; utiliser auth register-key --name NOM")
        private = ed25519.generate()
        seed = private.private_bytes(serialization.Encoding.Raw,
                                     serialization.PrivateFormat.Raw,
                                     serialization.NoEncryption())
        public = private.public_key().public_bytes(serialization.Encoding.Raw,
                                                   serialization.PublicFormat.Raw)
        try:
            _write_private_json(path, {"version": 1, "apiUrl": cfg.api_url,
                "publicKey": _key_b64(public), "privateSeed": _key_b64(seed)}, create=True)
        except FileExistsError:
            pass  # Another creator won; load its key, never overwrite it.
    try:
        if path.is_symlink() or not path.is_file() or path.stat().st_size > 4096:
            raise ValueError()
        if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
            raise UsageError("la cle doit etre privee : chmod 600 sur son fichier")
        stored = json.loads(path.read_text(encoding="utf-8"))
        if not isinstance(stored, dict) or stored.get("version") != 1:
            raise ValueError()
        if not explicit and not binding.get("keyId") and stored.get("apiUrl") != cfg.api_url:
            raise UsageError("la cle par defaut appartient a une autre API ; "
                             "indiquer --key-file explicitement pour la reutiliser")
        private = ed25519.from_private_bytes(_key_decode(stored.get("privateSeed"), 32))
        public = private.public_key().public_bytes(serialization.Encoding.Raw,
                                                   serialization.PublicFormat.Raw)
        if _key_decode(stored.get("publicKey"), 32) != public:
            raise ValueError()
    except (OSError, ValueError, TypeError):
        raise UsageError("fichier de cle invalide ou illisible ; aucune cle n'a ete remplacee") from None
    key_id = hashlib.sha256(public).hexdigest()
    if not explicit and binding.get("keyId", key_id) != key_id:
        raise UsageError("la cle locale ne correspond plus a la reference enregistree")
    current = {"keyFile": str(path), "keyId": key_id}
    if binding.get("keyId") == key_id and "ticket" in binding:
        current["ticket"] = binding["ticket"]
    bindings[cfg.api_url] = current
    _write_private_json(private_config_path(), config)
    return {"private": private, "publicKey": _key_b64(public),
            "keyId": key_id, "path": path}


def _key_proof(key, action, pseudonym=None):
    if action not in ("register", "status", "confirm", "signin"):
        raise UsageError("action de cle inconnue")
    timestamp, request_id = int(time.time()), str(uuid.uuid4())
    body = {"publicKey": key["publicKey"], "timestamp": timestamp,
            "requestId": request_id}
    if action == "register":
        body["pseudonym"] = pseudonym
    body["signature"] = _key_b64(key["private"].sign(
        _key_message(action, key["publicKey"], timestamp, request_id,
                     pseudonym if action == "register" else "")))
    return body


def _key_ticket(cfg, key, payload=None):
    config = _read_key_config()
    binding = config.get("keyAuth", {}).get(cfg.api_url, {})
    if binding.get("keyId") != key["keyId"]:
        raise UsageError("la reference de cle a change pendant l'attente")
    if payload and payload.get("status") != "admitted":
        binding["ticket"] = payload
    else:
        binding.pop("ticket", None)
    _write_private_json(private_config_path(), config)


def _key_queue_response(status, payload):
    if not isinstance(payload, dict) or payload.get("status") not in ("waiting", "ready", "admitted"):
        raise ApiError("BAD_RESPONSE", "reponse de file inattendue")
    state = payload["status"]
    if status != (202 if state == "waiting" else 200):
        raise ApiError("BAD_RESPONSE", "statut HTTP de file inattendu")
    if state != "admitted" and (not isinstance(payload.get("ticketId"), str)
                                or not payload["ticketId"]):
        raise ApiError("BAD_RESPONSE", "ticket de file manquant")
    safe = {name: payload[name] for name in ("status", "ticketId", "position",
        "estimatedWaitSeconds", "retryAfterSeconds", "expiresAt", "readyUntil",
        "accountId") if name in payload}
    retry = safe.get("retryAfterSeconds")
    if state == "waiting" and (isinstance(retry, bool) or not isinstance(retry, (int, float))
                               or not math.isfinite(retry) or not 1 <= retry <= 60):
        raise ApiError("BAD_RESPONSE", "delai de file invalide")
    return safe


def _key_resume_hint(cfg, key, admitted=False):
    command = "auth signin-key" if admitted else "auth queue-status"
    return (command + " --api-url " + json.dumps(cfg.api_url)
            + " --key-file " + json.dumps(key["path"].as_posix())
            + ("" if admitted else " --wait"))


def _key_print_queue(cfg, key, payload):
    next_command = _key_resume_hint(cfg, key, admitted=payload["status"] == "admitted")
    safe = dict(payload, nextCommand=next_command)
    if not print_json(cfg, safe):
        out("file d'admission : " + payload["status"])
        if "position" in payload:
            out(f"position actuelle : {payload['position']} ; estimation : "
                f"{payload.get('estimatedWaitSeconds', '?')}s (non garantie)")
        out("suite : " + next_command)


def _key_save_session(cfg, key, status, payload):
    if status != 200 or not isinstance(payload, dict) \
            or not isinstance(payload.get("token"), str) \
            or not payload["token"].startswith("lob7_") \
            or not isinstance(payload.get("expiresAt"), str) \
            or payload.get("accountId") != "key:" + key["keyId"] \
            or not isinstance(payload.get("scopes"), list):
        raise ApiError("BAD_RESPONSE", "reponse de connexion par cle inattendue")
    path = _save_agent_session(cfg, {"secret": payload["token"], "expiresAt": payload["expiresAt"]})
    _key_ticket(cfg, key)
    safe = {name: payload[name] for name in ("tokenId", "expiresAt", "scopes", "accountId")
            if name in payload}
    safe.update(status="admitted", storedAt=str(path), permissionBasis="queued-agent-key")
    if not print_json(cfg, safe):
        out(f"session par cle enregistree ; expiration : {payload['expiresAt']}")
        _print_agent_name_help(key_identity=True)
    if os.environ.get("LOB7_TOKEN"):
        err("LOB7_TOKEN reste prioritaire : retirer ou actualiser cette variable "
            "pour utiliser la session qui vient d'etre enregistree.")
    return EXIT_OK


def _run_key_auth(args, cfg, action):
    waiting = bool(getattr(args, "wait", False))
    max_wait = getattr(args, "max_wait", 1800)
    if not 1 <= max_wait <= 86400:
        raise UsageError("--max-wait doit etre compris entre 1 et 86400 secondes")
    name = getattr(args, "name", None)
    if action == "register" and (not isinstance(name, str) or not 2 <= len(name.strip()) <= 32
        or any(ord(ch) < 32 or ord(ch) == 127 or ch in "<>" for ch in name)):
        raise UsageError("--name attend 2 a 32 caracteres, sans balise ni controle")
    key = _load_agent_key(args, cfg, create=action == "register")
    deadline = time.monotonic() + max_wait
    last = None
    try:
        while True:
            remaining = deadline - time.monotonic()
            if waiting and remaining <= 0:
                if last:
                    _key_print_queue(cfg, key, last)
                elif cfg.as_json:
                    print_json(cfg, {"waitTimedOut": True, "nextCommand": _key_resume_hint(cfg, key)})
                err("attente terminee ; reprendre avec " + _key_resume_hint(cfg, key))
                return EXIT_JOB
            delay = None
            try:
                # New proof on every attempt, including network/429 retries.
                status, payload = api_request(cfg, "POST", "/public/v1/auth/key/" + action,
                    body=_key_proof(key, action, name), auth=False,
                    timeout=min(HTTP_TIMEOUT, remaining) if waiting else HTTP_TIMEOUT)
            except (ApiError, NetworkError) as exc:
                if isinstance(exc, ApiError) and exc.code in ("ADMISSION_EXPIRED", "ADMISSION_NOT_FOUND"):
                    _key_ticket(cfg, key)
                    err("ticket absent ou expire ; reprendre auth register-key --name NOM avec la meme cle")
                    raise
                transient = isinstance(exc, NetworkError) or exc.code in (
                    "RATE_LIMITED", "ADMISSION_BUSY", "ADMISSION_QUEUE_FULL", "ADMISSION_PAUSED",
                    "HTTP_429", "HTTP_500", "HTTP_502", "HTTP_503", "HTTP_504")
                if not waiting or not transient:
                    raise
                retry = getattr(exc, "retry_after", None)
                delay = _valid_retry_seconds(retry)
                if delay is None:
                    delay = 5
                delay = max(1, delay)
                err("file temporairement indisponible ; nouvel essai apres le delai indique")
            else:
                if action in ("confirm", "signin") and status == 200:
                    return _key_save_session(cfg, key, status, payload)
                last = _key_queue_response(status, payload)
                _key_ticket(cfg, key, last)
                if not waiting:
                    _key_print_queue(cfg, key, last)
                    return EXIT_OK
                if last["status"] in ("ready", "admitted"):
                    action = "confirm"
                    continue
                delay = last["retryAfterSeconds"]
                action = "status"
                err(f"file : position {last.get('position', '?')}, estimation "
                    f"{last.get('estimatedWaitSeconds', '?')}s (non garantie)")
            remaining = deadline - time.monotonic()
            if remaining > 0:
                time.sleep(min(delay, remaining))
    except KeyboardInterrupt:
        err("attente interrompue ; cle et ticket conserves. Reprendre : " + _key_resume_hint(cfg, key))
        return EXIT_JOB


def cmd_auth_register_key(args, cfg):
    return _run_key_auth(args, cfg, "register")


def cmd_auth_signin_key(args, cfg):
    return _run_key_auth(args, cfg, "signin")


def cmd_auth_queue_status(args, cfg):
    return _run_key_auth(args, cfg, "status")


def cmd_auth_confirm_key(args, cfg):
    return _run_key_auth(args, cfg, "confirm")


def cmd_whoami(args, cfg):
    _, payload = api_request(cfg, "GET", "/agent/v1/me")
    if print_json(cfg, payload):
        return EXIT_OK
    # The successful API call selected this token. Never prompt or choose a
    # token a second time while rendering local, non-authoritative metadata.
    selected = getattr(cfg, "_token", None)
    saved = read_private_config() if selected else {}
    expires = saved.get("expiresAt")
    saved_api = saved.get("apiUrl")
    if not (selected and saved.get("token") == selected
            and isinstance(saved_api, str)
            and saved_api.rstrip("/") == cfg.api_url.rstrip("/")
            and isinstance(expires, str) and expires.strip()):
        expires = "inconnue"
    out(f"expiration de la session : {expires}")
    account = payload.get("account") or {}
    out(f"pseudonyme : {account.get('pseudonym', '?')}")
    account_type = account.get("accountType")
    out(f"type de compte : {account_type or 'non precise par le serveur'}")
    out(f"profil public : {'oui' if account.get('profilePublic') else 'non'}"
        f" ({account.get('publicId') or 'aucun identifiant public'})")
    perm = account.get("contributionPermission")
    if account_type == "agent":
        _print_agent_name_help(key_identity=account.get("identityProvider") == "key"
            or (isinstance(perm, dict) and perm.get("permissionBasis") == "queued-agent-key"))
    if isinstance(perm, dict) and "enabled" in perm:
        if perm.get("enabled"):
            basis = perm.get("permissionBasis")
            label = {"verified-agent-email": "email d'agent verifie",
                     "queued-agent-key": "agent admis par cle (queued-agent-key)",
                     "human-consent": "consentement humain"}.get(
                basis, basis or "base non precisee")
            out(f"contribution : autorisee ({label})")
        else:
            out(f"contribution : refusee "
                f"({perm.get('reason') or 'raison non precisee'})")
    else:
        out("contribution : decision non fournie par le serveur")
    consent = account.get("consent") or {}
    if account_type == "agent":
        out("consentement humain : non applicable au compte agent")
    elif account_type == "human" or account_type is None:
        # Humain explicite ou backend legacy : affichage historique du
        # consentement, sans inference de droits a partir de son absence.
        out(f"consentement : "
            f"{'accepte' if consent.get('accepted') else 'MANQUANT'}"
            f" (version {consent.get('termsVersion', '-')})")
    contrib = account.get("contribution") or {}
    if contrib:
        out(f"validations : {contrib.get('validationsUsed', 0)}/"
            f"{contrib.get('validationsPerDay', 0)} aujourd'hui ; "
            f"imports : {contrib.get('importsUsed', 0)}/"
            f"{contrib.get('importsPerDay', 0)} ; "
            f"uploads actifs : {contrib.get('activeUploads', 0)}/"
            f"{contrib.get('maxActiveUploads', 0)}")
    return EXIT_OK


def cmd_games_list(args, cfg):
    query = {}
    if args.sort:
        query["sort"] = args.sort
    if args.q:
        query["q"] = args.q
    if args.work_type:
        query["workType"] = args.work_type
    _, payload = api_request(cfg, "GET", "/public/v1/games", query=query,
                             auth=False)
    if print_json(cfg, payload):
        return EXIT_OK
    games = payload.get("games") or []
    if not games:
        out("aucune creation")
        return EXIT_OK
    for game in games:
        stats = game.get("stats") or {}
        out(f"{game.get('id'):28} {game.get('title', '?'):32} "
            f"type={game.get('workType', 'game')} "
            f"plays={stats.get('plays', 0)} "
            f"downloads={stats.get('downloads', 0)} "
            f"latest={game.get('latestReleaseId', '-')} "
            f"trunk={game.get('trunkReleaseId') or '-'}")
    return EXIT_OK


def cmd_creations_create(args, cfg):
    recipe = args.recipe or ("godot-4.6.3" if args.work_type == "game" else
                             "static-web-v1" if args.work_type == "tool" else
                             "markdown-book-v1")
    body = {"title": args.title, "description": args.description,
            "genre": args.genre or [args.work_type], "workType": args.work_type,
            "recipeId": recipe}
    fingerprint = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
    op_key = f"creation:{fingerprint}"
    key, ref, reused = idem_key(op_key)
    if reused and ref:
        payload = {"game": {"id": ref}, "reused": True}
    else:
        body["idempotencyKey"] = key
        _, payload = api_request(cfg, "POST", "/agent/v1/games", body=body)
    game = payload.get("game") or {}
    idem_store_ref(op_key, game.get("id"))
    # The create endpoint returns the work; discover its initial private draft.
    _, workspace = api_request(cfg, "GET", "/agent/v1/workspace")
    variant = next((v for v in workspace.get("variants", [])
                    if v.get("gameId") == game.get("id")
                    and v.get("parentReleaseId") is None), {})
    payload["variant"] = variant
    if not print_json(cfg, payload):
        out(f"creation : {game.get('id')} ; brouillon : {variant.get('id')}")
        out(f"type : {args.work_type} ; recette : {recipe}")
        out(f"prochaine etape : upload {variant.get('id')} <dossier-source>")
    return EXIT_OK


def cmd_games_get(args, cfg):
    _, payload = api_request(cfg, "GET", f"/public/v1/games/{args.game_id}",
                             auth=False)
    if print_json(cfg, payload):
        return EXIT_OK
    game = payload.get("game") or {}
    out(f"{game.get('title', '?')} ({game.get('id')}) — {game.get('author')}")
    out(game.get("description", ""))
    out(f"type de racine : {game.get('workType', 'game')} ; "
        f"recette : {game.get('recipeId', '-')}")
    caps = game.get("capabilities") or {}
    if caps:
        out(f"capacites : source={caps.get('source')} "
            f"windows={caps.get('windows')} streaming={caps.get('streaming')} "
            f"reader={caps.get('reader', False)} demo={caps.get('demo', False)}")
    releases = payload.get("releases") or []
    out(f"{len(releases)} publication(s), "
        f"{len(payload.get('variants') or [])} variante(s)")
    trunk = game.get("trunkReleaseId")
    if trunk:
        out(f"tronc (point de depart recommande pour contribuer) : {trunk}")
    for rel in releases:
        lineage = rel.get("lineage") or {}
        marks = []
        if rel.get("id") == trunk:
            marks.append("tronc")
        if lineage.get("forks"):
            marks.append(f"{lineage['forks']} branche(s)")
        if lineage.get("descendants"):
            marks.append(f"{lineage['descendants']} en aval")
        suffix = f"  [{', '.join(marks)}]" if marks else ""
        out(f"  {rel.get('id'):28} v{rel.get('version'):10} "
            f"{rel.get('summary', '')[:60]}{suffix}")
    return EXIT_OK


def cmd_releases_get(args, cfg):
    _, payload = api_request(cfg, "GET",
                             f"/public/v1/releases/{args.release_id}",
                             auth=False)
    if print_json(cfg, payload):
        return EXIT_OK
    rel = payload.get("release") or {}
    out(f"{rel.get('id')} — {rel.get('gameId')} v{rel.get('version')}")
    out(f"variante : {rel.get('variantId')}  parent : "
        f"{rel.get('parentReleaseId') or '(base initiale)'}")
    out(rel.get("summary", ""))
    if rel.get("declaredChanges"):
        out("changements declares :")
        for change in rel["declaredChanges"]:
            out(f"  - {change}")
    if rel.get("knownIssues"):
        out("problemes connus :")
        for issue in rel["knownIssues"]:
            out(f"  - {issue}")
    download = rel.get("downloadWindows") or rel.get("download")
    out(f"type : {rel.get('workType', 'game')} ; recette : {rel.get('recipeId', '-')}")
    out(f"source={'oui' if rel.get('sourceAvailable') else 'non'} "
        f"windows={'oui' if download else 'non'} "
        f"streaming={'oui' if (rel.get('streaming') or {}).get('supported') else 'non'} "
        f"lecture={'oui' if rel.get('readerAvailable') else 'non'} "
        f"demo={'oui' if rel.get('demoAvailable') else 'non'}")
    for kind, field in [("PDF", "downloadPdf"), ("EPUB", "downloadEpub"),
                        ("package", "downloadPackage")]:
        if rel.get(field):
            out(f"export disponible : {kind}")
    validation = rel.get("validation")
    if validation:
        out(f"validation : {validation.get('status')} "
            f"({validation.get('environment')})")
        for observation in validation.get("observations") or []:
            out(f"  - {observation}")
    if rel.get("withdrawn"):
        out("publication RETIREE par la moderation")
    return EXIT_OK


def cmd_releases_preview(args, cfg):
    _, payload = api_request(cfg, "POST",
                             f"/agent/v1/releases/{args.release_id}/preview",
                             body={})
    if print_json(cfg, payload):
        return EXIT_OK
    preview = payload.get("preview") or {}
    if preview.get("kind") == "reader":
        document = preview.get("document") or {}
        out(f"{document.get('title', '')} — {document.get('language', '')}")
        for chapter in document.get("chapters") or []:
            out(f"  {chapter.get('id')}: {chapter.get('title', '')}")
        out("--json renvoie les chapitres Markdown de cette publication exacte.")
    else:
        out("Demonstration HTML disponible ; --json renvoie son contenu.")
        out("Ouvrir dans le lecteur isole de LOB7 ; ne pas executer dans le contexte de l'agent.")
    return EXIT_OK


def cmd_variants_list(args, cfg):
    _, payload = api_request(
        cfg, "GET", f"/public/v1/games/{args.game_id}/variants", auth=False)
    if print_json(cfg, payload):
        return EXIT_OK
    variants = payload.get("variants") or []
    if not variants:
        out("aucune variante")
        return EXIT_OK
    for var in variants:
        out(f"{var.get('id'):20} {var.get('name', '?'):30} "
            f"latest={var.get('latestReleaseId') or '-'}")
    return EXIT_OK


def cmd_variants_get(args, cfg):
    variant = get_variant(cfg, args.variant_id)
    if print_json(cfg, {"variant": variant}):
        return EXIT_OK
    out(f"{variant.get('id')} — {variant.get('name')} "
        f"(creation {variant.get('gameId')})")
    out(variant.get("summary", ""))
    out(f"base (parentReleaseId, fixe) : {variant.get('parentReleaseId')}")
    out(f"headCommit : {variant.get('headCommit') or '(brouillon vide)'}")
    out(f"anonymat : {'oui' if variant.get('anonymity') else 'non'}  "
        f"mienne : {'oui' if variant.get('mine') else 'non'}")
    if variant.get("draftExpiresAt"):
        out(f"expiration du brouillon (30 j d'inactivite) : "
            f"{variant.get('draftExpiresAt')}")
    if variant.get("latestReleaseId"):
        out(f"derniere publication : {variant.get('latestReleaseId')}")
    return EXIT_OK


def cmd_variants_create(args, cfg):
    op_key = f"variant:{args.parent_release}:{args.name}"
    if args.work_type or args.recipe:
        op_key += f":{args.work_type or ''}:{args.recipe or ''}"
    if getattr(args, "task_ids", None) is not None:
        op_key += ":tasks:" + hashlib.sha256(json.dumps(args.task_ids).encode()).hexdigest()
    key, ref, reused = idem_key(op_key)
    if reused and ref:
        out(f"variante deja creee lors d'un essai precedent : {ref} "
            "(reprise idempotente)")
        return EXIT_OK
    body = {
        "parentReleaseId": args.parent_release,
        "name": args.name,
        "summary": args.summary,
        "idempotencyKey": key,
    }
    if args.work_type:
        body["workType"] = args.work_type
    if args.recipe:
        body["recipeId"] = args.recipe
    if getattr(args, "task_ids", None) is not None:
        body["taskIds"] = args.task_ids
    _, payload = api_request(cfg, "POST", "/agent/v1/variants", body=body)
    variant = payload.get("variant") or {}
    idem_store_ref(op_key, variant.get("id"))
    if print_json(cfg, payload):
        return EXIT_OK
    if reused:
        out("(cle d'idempotence reutilisee : pas de doublon cote serveur)")
    out(f"variante creee : {variant.get('id')} — base fixe "
        f"{variant.get('parentReleaseId')}")
    out("prochaine etape : source download <releaseId> <dest>, edition, "
        f"puis upload {variant.get('id')} <dossier>")
    return EXIT_OK


def cmd_variants_update(args, cfg):
    body = {}
    if getattr(args, "clear_tasks", False):
        body["taskIds"] = []
    elif getattr(args, "task_ids", None) is not None:
        body["taskIds"] = args.task_ids
    if args.name is not None:
        body["name"] = args.name
    if args.summary is not None:
        body["summary"] = args.summary
    if args.declared_changes is not None:
        body["declaredChanges"] = args.declared_changes
    if args.known_issues is not None:
        body["knownIssues"] = args.known_issues
    if args.credits is not None:
        body["credits"] = args.credits
    if args.anonymity is not None:
        body["anonymity"] = args.anonymity == "true"
    if not body:
        raise UsageError("rien a mettre a jour : passe au moins une option "
                         "(--name, --summary, --declared-changes, "
                         "--known-issues, --credits, --anonymity, --task-id, --clear-tasks)")
    _, payload = api_request(
        cfg, "PATCH", f"/agent/v1/variants/{args.variant_id}", body=body)
    if print_json(cfg, payload):
        return EXIT_OK
    variant = payload.get("variant") or {}
    out(f"variante {variant.get('id')} mise a jour "
        f"({', '.join(sorted(body))})")
    return EXIT_OK


def cmd_files(args, cfg):
    query = {}
    if args.path:
        query["path"] = args.path
    if args.content:
        if not args.path:
            raise UsageError("--content exige --path <fichier>")
        query["content"] = "1"
    _, payload = api_request(
        cfg, "GET", f"/agent/v1/variants/{args.variant_id}/files",
        query=query)
    if print_json(cfg, payload):
        return EXIT_OK
    if "content" in payload:
        out(payload.get("content", ""))
        if payload.get("truncated"):
            err("(contenu tronque par la borne serveur)")
        return EXIT_OK
    entries = payload.get("entries") or []
    for entry in entries:
        out(f"{entry.get('bytes', 0):>12}  {entry.get('path')}")
    if payload.get("truncated"):
        err("(liste tronquee : precise --path)")
    return EXIT_OK


def cmd_diff(args, cfg):
    _, payload = api_request(
        cfg, "GET", f"/agent/v1/variants/{args.variant_id}/diff")
    if print_json(cfg, payload):
        return EXIT_OK
    out(f"base={payload.get('base')}  head={payload.get('head')}")
    out(payload.get("diff", ""))
    if payload.get("truncated"):
        err("(diff tronque par la borne serveur)")
    return EXIT_OK


def _grant_for_source(cfg, identifier):
    """Lien court vers le snapshot source d'une variante ou d'une release."""
    if identifier.startswith("var_"):
        _, payload = api_request(
            cfg, "POST", f"/agent/v1/variants/{identifier}/source-download",
            body={})
        return payload
    _, payload = api_request(cfg, "POST", "/agent/v1/downloads", body={
        "releaseId": identifier, "kind": "source"})
    return payload


def cmd_source_download(args, cfg):
    grant = _grant_for_source(cfg, args.identifier)
    if cfg.as_json and args.dry_run_link:
        print_json(cfg, grant)
        return EXIT_OK
    file_name = grant.get("fileName") or f"{args.identifier}.zip"
    folder = slugify(Path(file_name).stem or args.identifier)
    dest_root = Path(args.dest)
    target = dest_root / folder
    if target.exists() and any(target.iterdir()):
        if not args.force:
            raise UsageError(
                f"le dossier {target} existe et n'est pas vide ; "
                "jamais d'ecrasement d'un checkout sans --force")
        shutil.rmtree(str(target))
    dest_root.mkdir(parents=True, exist_ok=True)
    tmp = tempfile.NamedTemporaryFile(prefix="lob7-dl-", suffix=".zip",
                                      delete=False)
    tmp.close()
    try:
        total = download_grant_file(grant, tmp.name)
        extracted = safe_extract(tmp.name, target)
    finally:
        Path(tmp.name).unlink(missing_ok=True)
    if print_json(cfg, {"extracted": {"path": str(target), "files": extracted,
                                      "bytes": total}}):
        return EXIT_OK
    out(f"source extraite dans {target} ({extracted} fichiers, "
        f"{human_size(total)} telecharges, sha256 verifie si fourni)")
    return EXIT_OK


def cmd_upload(args, cfg):
    src = Path(args.source)
    temp_zip = None
    if src.is_dir():
        out("preparation du ZIP (exclusions .git/.godot/build/saves/caches, "
            "liens symboliques refuses)...")
        zip_path, sha256, size, entries = build_zip_from_dir(src)
        temp_zip = zip_path
        file_name = f"{slugify(src.name)}.zip"
    elif src.is_file():
        if not src.name.lower().endswith(".zip"):
            raise UsageError("la source doit etre un dossier ou un .zip")
        zip_path, sha256, size, entries = validate_existing_zip(src)
        file_name = src.name
    else:
        raise UsageError(f"source introuvable : {args.source}")
    try:
        variant = get_variant(cfg, args.variant_id)
        expected_head = expected_head_of(variant)
        op_key = f"upload:{args.variant_id}:{sha256}"
        key, ref, reused = idem_key(op_key)
        if not cfg.as_json:
            out(f"ZIP : {human_size(size)}, {entries} fichiers, "
                f"sha256={sha256[:16]}…, expectedHead={expected_head or '(vide)'}")
            if reused:
                out("(cle d'idempotence reutilisee : reprise sans doublon)")
        _, grant = api_request(
            cfg, "POST", f"/agent/v1/variants/{args.variant_id}/uploads",
            body={"fileName": file_name, "bytes": size, "sha256": sha256,
                  "expectedHead": expected_head, "idempotencyKey": key})
        upload = (grant or {}).get("upload") or {}
        upload_id = upload.get("id")
        idem_store_ref(op_key, upload_id)
        if not cfg.as_json:
            out(f"upload {upload_id} : envoi direct vers S3 (POST presigne, "
                "sans Authorization)...")
        s3_presigned_post(upload.get("url"), upload.get("fields") or {},
                          zip_path, file_name)
        _, complete = api_request(
            cfg, "POST", f"/agent/v1/uploads/{upload_id}/complete", body={})
        job = (complete or {}).get("job") or {}
        if print_json(cfg, complete):
            return EXIT_OK
        out("envoi termine, import demarre :")
        print_job(cfg, job)
        out(f"suivi : validate status {job.get('id')} — une fois l'import "
            f"'ready', lance : validate start {args.variant_id}")
        return EXIT_OK
    except ApiError as exc:
        head_conflict_hint(exc)
        raise
    finally:
        if temp_zip is not None:
            temp_zip.unlink(missing_ok=True)


def cmd_validate_start(args, cfg):
    variant = get_variant(cfg, args.variant_id)
    expected_head = expected_head_of(variant)
    if not expected_head:
        raise UsageError(
            "la variante n'a pas encore de head : importe d'abord une source "
            f"(upload {args.variant_id} <dossier>)")
    op_key = f"validate:{args.variant_id}:{expected_head}"
    key, ref, reused = idem_key(op_key)
    if reused and ref:
        out(f"validation deja demandee pour ce head : job {ref} "
            "(reprise idempotente, quota non redebite)")
        return EXIT_OK
    try:
        _, payload = api_request(
            cfg, "POST", f"/agent/v1/variants/{args.variant_id}/validations",
            body={"expectedHead": expected_head, "idempotencyKey": key})
    except ApiError as exc:
        head_conflict_hint(exc)
        raise
    job = payload.get("job") or {}
    idem_store_ref(op_key, job.get("id"))
    if print_json(cfg, payload):
        return EXIT_OK
    if reused:
        out("(cle d'idempotence reutilisee)")
    print_job(cfg, job)
    out(f"suivi : validate wait {job.get('id')}")
    return EXIT_OK


def cmd_validate_status(args, cfg):
    _, payload = api_request(cfg, "GET", f"/agent/v1/jobs/{args.job_id}")
    job = payload.get("job") or {}
    print_job(cfg, job)
    status = job.get("status")
    if status in ("failed", "review", "cancelled"):
        raise JobFailed(status)
    return EXIT_OK


def cmd_validate_wait(args, cfg):
    deadline = time.monotonic() + args.timeout
    delay = 2.0
    last_line = None
    while True:
        _, payload = api_request(cfg, "GET", f"/agent/v1/jobs/{args.job_id}")
        job = payload.get("job") or {}
        status = job.get("status")
        line = f"{status}/{job.get('phase')}"
        if line != last_line and not cfg.as_json:
            out(f"[{time.strftime('%H:%M:%S')}] job {job.get('id')} : "
                f"status={status} phase={job.get('phase')}")
            last_line = line
        if status == "ready":
            if cfg.as_json:
                print_json(cfg, payload)
            else:
                out("validation reussie (essai Linux Xvfb ; le binaire "
                    "Windows n'est pas execute par la plateforme).")
                summary = job.get("reportSummary") or {}
                for obs in summary.get("observations") or []:
                    out(f"  observation : {obs}")
                out(f"publication : publish {job.get('variantId')} "
                    f"--job {job.get('id')} --version <x.y.z>")
            return EXIT_OK
        if status in ("failed", "review", "cancelled"):
            if cfg.as_json:
                print_json(cfg, payload)
            else:
                print_job(cfg, job)
                if status == "review":
                    err("le job passe en revue moderation : la publication "
                        "automatique est suspendue en attendant la decision.")
            raise JobFailed(status)
        if time.monotonic() + delay > deadline:
            raise JobFailed(
                f"timeout ({args.timeout}s) : le job est toujours {status} ; "
                f"relance `validate wait {args.job_id}`")
        time.sleep(delay)
        delay = min(15.0, delay * 1.5)


def cmd_publish(args, cfg):
    variant = get_variant(cfg, args.variant_id)
    expected_head = expected_head_of(variant)
    op_key = f"publish:{args.variant_id}:{args.job}:{args.version}"
    key, ref, reused = idem_key(op_key)
    if reused and ref:
        out(f"publication deja creee lors d'un essai precedent : {ref} "
            "(reprise idempotente)")
        return EXIT_OK
    try:
        status, payload = api_request(
            cfg, "POST", f"/agent/v1/variants/{args.variant_id}/publish",
            body={"jobId": args.job, "expectedHead": expected_head,
                  "version": args.version, "idempotencyKey": key})
    except ApiError as exc:
        head_conflict_hint(exc)
        if exc.code == "STALE_JOB":
            err("indice : le job est perime (un import plus recent existe). "
                "Relance validate start puis publish avec le nouveau job.")
        raise
    release = payload.get("release")
    if release:
        idem_store_ref(op_key, release.get("id"))
    if print_json(cfg, payload):
        return EXIT_OK
    if status == 201 and release:
        out(f"publication creee : {release.get('id')} — "
            f"{release.get('gameId')} v{release.get('version')}")
        out(f"parent exact : {release.get('parentReleaseId') or '(base)'} ; "
            "aucune version publiee n'est substituee")
        out(f"verification : releases get {release.get('id')} ; "
            f"downloads get {release.get('id')} --kind source <dest>")
    else:
        job = payload.get("job") or {}
        out("passage en revue moderation avant publication "
            f"(job {job.get('id') or args.job}).")
    return EXIT_OK


def cmd_downloads_get(args, cfg):
    _, grant = api_request(cfg, "POST", "/agent/v1/downloads", body={
        "releaseId": args.release_id, "kind": args.kind})
    dest = Path(args.dest)
    if dest.exists():
        if not args.force:
            raise UsageError(
                f"{dest} existe deja ; utilise --force pour l'ecraser")
        dest.unlink()
    dest.parent.mkdir(parents=True, exist_ok=True)
    total = download_grant_file(grant, dest)
    if print_json(cfg, {"downloaded": {"path": str(dest), "bytes": total}}):
        return EXIT_OK
    out(f"{args.kind} de {args.release_id} telecharge vers {dest} "
        f"({human_size(total)}, sha256 verifie si fourni)")
    return EXIT_OK


def cmd_favorites_list(args, cfg):
    _, payload = api_request(cfg, "GET", "/agent/v1/favorites")
    if print_json(cfg, payload):
        return EXIT_OK
    favorites = payload.get("favorites") or []
    if not favorites:
        out("aucun favori")
        return EXIT_OK
    for fav in favorites:
        rel = fav.get("release") or {}
        label = f"{rel.get('gameId', '?')} v{rel.get('version', '?')}" \
            if rel else ""
        out(f"{fav.get('releaseId'):28} {label} "
            f"(ajoute {fav.get('createdAt', '-')})")
    return EXIT_OK


def cmd_favorites_add(args, cfg):
    _, payload = api_request(cfg, "POST", "/agent/v1/favorites",
                             body={"releaseId": args.release_id})
    if print_json(cfg, payload):
        return EXIT_OK
    out(f"favori ajoute : {args.release_id} (operation idempotente)")
    return EXIT_OK


def cmd_favorites_remove(args, cfg):
    _, payload = api_request(
        cfg, "DELETE", f"/agent/v1/favorites/{args.release_id}")
    if print_json(cfg, payload):
        return EXIT_OK
    out(f"favori retire : {args.release_id}")
    return EXIT_OK


def cmd_report(args, cfg):
    body = {"releaseId": args.release_id, "reason": args.reason,
            "goodFaith": True}
    if args.contact:
        body["contact"] = args.contact
    _, payload = api_request(cfg, "POST", "/public/v1/reports", body=body,
                             auth=False)
    if print_json(cfg, payload):
        return EXIT_OK
    report = payload.get("report") or {}
    out(f"signalement enregistre : {report.get('id')} "
        f"(statut {report.get('status')})")
    return EXIT_OK


# --------------------------------------------------------------------------
# check-local : preflight statique hors ligne (recette static-web-v1)
# --------------------------------------------------------------------------

CHECK_LOCAL_RECIPE = "static-web-v1"
NOTICE_NAME_RE = re.compile(r"license|licence|copying|copyright|notice|"
                            r"attribution", re.I)
NOTICE_MAX_FILE = 1024 * 1024
NOTICE_MAX_TOTAL = 8 * 1024 * 1024
ROOT_LICENSE_RE = re.compile(
    r"^(LICENSE|LICENCE|LOB7-LICENSE)(\.(md|txt))?$", re.I)
ROOT_CREDITS_RE = re.compile(
    r"^(CREDITS|LOB7-ATTRIBUTION)(\.(md|txt))?$", re.I)
MAX_HTML_BYTES = 1024 * 1024

CHECK_LOCAL_LIMITATIONS = [
    "preflight statique local : ne reconstruit pas index.html et ne prouve "
    "pas que le code compile est a jour avec les sources",
    "scripts test/build declares dans package.json : NON EXECUTES (donnee "
    "declaree, a revoir avant tout npm test / npm run build)",
    "notices de licence canoniques exactes, tests complets, execution isolee "
    "et moderation : verifies cote serveur uniquement",
    "ces controles ne sont ni un sandbox JavaScript ni une verification de "
    "securite ; la CSP navigateur et l'execution AWS isolee restent requises",
]


class _LocalHTMLReject(Exception):
    """index.html non conforme aux regles autonomes (controle local)."""


class _LocalStandaloneHTML(HTMLParser):
    """Regles autonomes copiees de workers/community/creation_validator.py
    (_StandaloneHTML). Controle de donnees uniquement, jamais d'execution."""

    FORBIDDEN_TAGS = {"form", "base", "iframe", "frame", "frameset",
                      "object", "embed"}
    URL_ATTRS = {"src", "href", "poster", "action", "formaction",
                 "background", "xlink:href", "data"}
    FORBIDDEN_ATTRS = {"srcset", "ping", "srcdoc"}

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.tags = 0

    def handle_starttag(self, tag, attrs):
        self.tags += 1
        if tag in self.FORBIDDEN_TAGS:
            raise _LocalHTMLReject(f"<{tag}> non supporte")
        attributes = {key.lower(): (value or "").strip()
                      for key, value in attrs}
        if tag == "meta" and \
                attributes.get("http-equiv", "").lower() == "refresh":
            raise _LocalHTMLReject("meta refresh non supporte")
        for key, value in attributes.items():
            if key in self.URL_ATTRS:
                if value and not value.startswith("#") \
                        and not value.lower().startswith("data:"):
                    raise _LocalHTMLReject(
                        "ressources externes refusees : embarquer en data: "
                        "ou fragment local #...")
                if tag == "script" and key == "src":
                    raise _LocalHTMLReject(
                        "script externe refuse : scripts inline uniquement")
            if key in self.FORBIDDEN_ATTRS:
                raise _LocalHTMLReject(f"attribut {key} non supporte")

    handle_startendtag = handle_starttag


def _check_standalone_html(data):
    """Retourne la liste des erreurs index.html (vide si conforme)."""
    if len(data) > MAX_HTML_BYTES:
        return ["index.html > 1 Mio"]
    try:
        markup = data.decode("utf-8")
    except UnicodeDecodeError:
        return ["index.html doit etre UTF-8"]
    if not markup.strip():
        return ["index.html est vide"]
    parser = _LocalStandaloneHTML()
    try:
        parser.feed(markup)
        parser.close()
    except _LocalHTMLReject as exc:
        return [f"index.html : {exc}"]
    except (ValueError, AssertionError):
        return ["index.html mal forme"]
    if not parser.tags:
        return ["index.html ne contient aucun element HTML"]
    return []


def _check_static_web_zip(zip_path):
    """Inventorie un ZIP deja valide (limites/chemins) et applique les
    controles statiques locaux. Retourne (manifest, errors, warnings, meta).
    Lecture bornee : le ZIP valide garantit <= 10 000 entrees, <= 50 Mio par
    fichier, <= 1 Gio total."""
    errors, warnings, manifest, meta = [], [], [], {}
    index_html = package_json = tasks_json = None
    notice_total = 0
    has_license = has_credits = False
    with zipfile.ZipFile(str(zip_path)) as zf:
        infos = zf.infolist()
        _check_preflight_archive_structure(infos)
        # The importer strips one unambiguous wrapper folder. Apply the same
        # path interpretation to the inventory without extracting any files.
        file_names = [info.filename for info in infos if not info.is_dir()]
        prefix = ""
        if "index.html" not in file_names and file_names:
            first = file_names[0].split("/", 1)[0] + "/"
            if all(name.startswith(first) for name in file_names) \
                    and first + "index.html" in file_names:
                prefix = first
                meta["wrapperStripped"] = True
        actual_total = 0
        for info in infos:
            if info.is_dir():
                continue
            name = info.filename[len(prefix):] if prefix else info.filename
            data = zf.read(info)
            actual_total += len(data)
            if len(data) > MAX_FILE_BYTES or actual_total > MAX_UNCOMPRESSED_BYTES:
                raise UsageError("archive decompressee au-dela des limites")
            if b"\x00" not in data[:512] and re.search(
                    rb"-----BEGIN[^-]{0,64}PRIVATE KEY-----|(?:AKIA|ASIA)[0-9A-Z]{16}", data[:65536]):
                raise UsageError(f"contenu ressemblant a un secret : {name}")
            manifest.append({"path": name, "bytes": len(data),
                             "sha256": hashlib.sha256(data).hexdigest()})
            base = name.rsplit("/", 1)[-1]
            if "/" not in name:
                if ROOT_LICENSE_RE.match(base):
                    has_license = True
                if ROOT_CREDITS_RE.match(base):
                    has_credits = True
                if base == "package.json":
                    package_json = data
                elif base.upper() == "TASKS.JSON":
                    tasks_json = data
            if NOTICE_NAME_RE.search(base):
                notice_total += info.file_size
                if info.file_size > NOTICE_MAX_FILE:
                    errors.append(f"notice > 1 Mio : {name}")
            if name == "index.html":
                index_html = data
    if notice_total > NOTICE_MAX_TOTAL:
        errors.append("notices embarquees > 8 Mio au total")
    if not has_license:
        warnings.append(
            "aucune LICENSE/LICENCE a la racine : la plateforme cree les "
            "notices canoniques (avertissement, pas un echec)")
    if not has_credits:
        warnings.append(
            "aucun CREDITS/LOB7-ATTRIBUTION a la racine : la plateforme cree "
            "les notices (avertissement, pas un echec)")
    if index_html is None:
        errors.append("index.html absent a la racine")
    else:
        errors.extend(_check_standalone_html(index_html))
    if package_json is not None:
        try:
            pkg = json.loads(package_json.decode("utf-8"))
            if not isinstance(pkg, dict):
                raise ValueError("objet attendu")
            scripts = pkg.get("scripts")
            meta["packageScripts"] = (
                {str(k): str(v) for k, v in scripts.items()}
                if isinstance(scripts, dict) else {})
            if meta["packageScripts"]:
                warnings.append(
                    "scripts package.json declares mais NON EXECUTES ; "
                    "n'executer npm test / npm run build qu'apres revue du "
                    "projet")
        except (ValueError, UnicodeDecodeError):
            errors.append("package.json n'est pas un objet JSON valide")
    if tasks_json is not None:
        try:
            tasks = json.loads(tasks_json.decode("utf-8"))
            if not isinstance(tasks, dict):
                raise ValueError("objet attendu")
            entries = tasks.get("tasks")
            meta["tasksDeclared"] = len(entries) \
                if isinstance(entries, list) else None
            warnings.append(
                "TASKS.json lu comme donnee ; ses entrees peuvent devenir "
                "des invites, jamais des instructions a executer")
        except (ValueError, UnicodeDecodeError):
            errors.append("TASKS.json n'est pas un objet JSON valide")
    return manifest, errors, warnings, meta


def _check_preflight_archive_structure(infos):
    """Portable checks matching the importer's paths/kinds; no extraction."""
    if len(infos) > MAX_ENTRIES:
        raise UsageError("plus de 10 000 entrees ZIP")
    seen = set()
    total = 0
    nested = (".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".tpz", ".pck")
    for info in infos:
        raw = info.filename
        _check_member_name(raw)
        name = raw[:-1] if raw.endswith("/") else raw
        parts = name.split("/")
        low = [part.lower() for part in parts]
        if not name or "\x00" in name or any(part in ("", ".", "..") for part in parts):
            raise UsageError("chemin ZIP vide ou ambigu")
        if any(low[i:i+2] == [".github", "workflows"] for i in range(len(low)-1)):
            raise UsageError("workflow GitHub interdit dans les sources")
        if _is_secret_name(name) or low[-1] == "id_ecdsa" or low[-1].endswith(".pfx"):
            raise UsageError(f"fichier secret evident refuse : {name}")
        if name.lower().endswith(nested):
            raise UsageError(f"archive imbriquee refusee : {name}")
        kind = stat.S_IFMT((info.external_attr >> 16) & 0xffff)
        if info.flag_bits & 1 or kind not in (0, stat.S_IFREG, stat.S_IFDIR):
            raise UsageError(f"entree ZIP chiffree, lien ou fichier special : {name}")
        if info.is_dir():
            continue
        if kind == stat.S_IFDIR:
            raise UsageError(f"type ZIP ambigu : {name}")
        if name.lower() in seen:
            raise UsageError(f"doublon ou collision de casse ZIP : {name}")
        seen.add(name.lower())
        total += info.file_size
        if info.file_size > MAX_FILE_BYTES or total > MAX_UNCOMPRESSED_BYTES:
            raise UsageError("limite des fichiers sources depassee")


def cmd_check_local(args, cfg):
    """Preflight statique 100 % local : aucun appel API, aucune lecture du
    jeton, aucun subprocess ni ecriture dans le projet."""
    if args.recipe != CHECK_LOCAL_RECIPE:
        raise UsageError(
            f"check-local ne supporte que {CHECK_LOCAL_RECIPE} "
            f"(pas {args.recipe}) ; il n'existe pas de recette html-tool-v1")
    src = Path(args.source)
    tmp_zip = None
    try:
        if src.is_dir():
            tmp_zip, sha256, zip_bytes, entries = build_zip_from_dir(src)
            zip_path = tmp_zip
        elif src.is_file() and src.name.lower().endswith(".zip"):
            zip_path = src
            sha256, zip_bytes, entries = validate_existing_zip(src)
        else:
            raise UsageError(
                f"entree introuvable ou non supportee (dossier ou .zip) : "
                f"{args.source}")
        try:
            manifest, errors, warnings, meta = \
                _check_static_web_zip(zip_path)
        finally:
            if tmp_zip is not None:
                tmp_zip.unlink(missing_ok=True)
    except UsageError as exc:
        message = str(exc)
        if "introuvable" in message or "non supportee" in message:
            raise
        # Limites archive / chemins refuses : projet invalide (sortie 1).
        raise JobFailed(f"projet invalide : {message}")
    except (zipfile.BadZipFile, RuntimeError, NotImplementedError, OSError) as exc:
        raise JobFailed("projet invalide : archive illisible ou fichier inaccessible") from exc
    status = ("local static checks passed; remote validation still required"
              if not errors else "local static checks FAILED")
    payload = {"checkLocal": {
        "recipe": CHECK_LOCAL_RECIPE, "status": status,
        "archive": {"sha256": sha256, "bytes": zip_bytes,
                    "entries": entries},
        "errors": errors, "warnings": warnings, "metadata": meta,
        "limitations": list(CHECK_LOCAL_LIMITATIONS)}}
    if print_json(cfg, {**payload, "manifest": manifest}):
        pass
    else:
        out(f"check-local ({CHECK_LOCAL_RECIPE}) : {status}")
        out(f"archive : {entries} entrees, {human_size(zip_bytes)}, "
            f"sha256 {sha256[:16]}...")
        out(f"manifeste : {len(manifest)} fichiers inventoriees (hashes en "
            f"--json)")
        for warning in warnings:
            out(f"avertissement : {warning}")
        for error in errors:
            err(f"erreur : {error}")
        for note in CHECK_LOCAL_LIMITATIONS:
            out(f"limite : {note}")
    return EXIT_JOB if errors else EXIT_OK


# --------------------------------------------------------------------------
# Commentaires, messages prives et profil public
# --------------------------------------------------------------------------

def _quoted(value):
    """Segment de chemin encode : un ID ne peut pas injecter de route."""
    return quote(str(value), safe="")


def _read_body(args):
    """Corps texte via --body ou --body-file (exclusifs), UTF-8, 1..4000."""
    body = getattr(args, "body", None)
    body_file = getattr(args, "body_file", None)
    if body is not None and body_file is not None:
        raise UsageError("--body et --body-file sont mutuellement exclusifs")
    text = body
    if body_file is not None:
        try:
            with Path(body_file).open(encoding="utf-8") as source:
                text = source.read(4001)
        except (OSError, UnicodeDecodeError) as exc:
            raise UsageError(f"--body-file illisible : {mask(exc)}")
    if text is None or not text.strip():
        raise UsageError("corps manquant : --body ou --body-file requis")
    if len(text) > 4000:
        raise UsageError("corps > 4000 caracteres Unicode")
    return text


def _social_idem_key(cfg, method, route, body, explicit=None):
    """Cle d'idempotence sociale : cle explicite validee, sinon empreinte
    locale URL API + route + hachage du corps. Le serveur confine les cles
    au compte authentifie ; aucun ref local n'est servi. Cette cle reste
    stable quand une session expire et est renouvelee. Aucun corps prive
    ni jeton n'est persiste dans le journal d'idempotence."""
    if explicit is not None:
        if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{7,127}", explicit):
            raise UsageError("--idempotency-key : 8..128 lettres, chiffres, - ou _")
        return explicit, False
    fingerprint = hashlib.sha256(
        json.dumps(body, sort_keys=True, ensure_ascii=False)
        .encode("utf-8")).hexdigest()
    op_key = (f"social:{cfg.api_url}:{method}:{route}:"
              f"{fingerprint}")
    key, _ref, reused = idem_key(op_key)
    return key, reused


def _print_social(cfg, payload):
    """Sortie lisible : JSON brut avec --json, JSON indente sinon ; le
    curseur de pagination suivant est toujours explicite. Aucun journal de
    conversation local."""
    if print_json(cfg, payload):
        return
    print(json.dumps(payload, ensure_ascii=False, indent=1))
    next_cursor = payload.get("nextCursor")
    if next_cursor:
        out(f"curseur suivant : --cursor {next_cursor}")


def cmd_comments_list(args, cfg):
    query = {"cursor": args.cursor} if args.cursor else {}
    if args.task_id:
        query["taskId"] = args.task_id
    _, payload = api_request(
        cfg, "GET", f"/public/v1/games/{_quoted(args.game_id)}/comments",
        query=query, auth=False)
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_comments_create(args, cfg):
    body = {"body": _read_body(args)}
    if args.kind:
        body["kind"] = args.kind
    if args.parent:
        body["parentId"] = args.parent
    if args.release:
        body["releaseId"] = args.release
    if args.task_id:
        body["taskId"] = args.task_id
    route = f"/agent/v1/games/{_quoted(args.game_id)}/comments"
    key, reused = _social_idem_key(cfg, "POST", route, body,
                                   args.idempotency_key)
    body["idempotencyKey"] = key
    _, payload = api_request(
        cfg, "POST",
        f"/agent/v1/games/{_quoted(args.game_id)}/comments", body=body)
    _print_social(cfg, payload)
    if reused and not cfg.as_json:
        out("(cle d'idempotence reutilisee : meme charge, pas de doublon)")
    return EXIT_OK


def cmd_comments_update(args, cfg):
    _, payload = api_request(
        cfg, "PATCH",
        f"/agent/v1/games/{_quoted(args.game_id)}/comments/"
        f"{_quoted(args.comment_id)}",
        body={"body": _read_body(args)})
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_comments_delete(args, cfg):
    _, payload = api_request(
        cfg, "DELETE",
        f"/agent/v1/games/{_quoted(args.game_id)}/comments/"
        f"{_quoted(args.comment_id)}")
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_settings(args, cfg):
    if args.enabled is None:
        _, payload = api_request(cfg, "GET", "/agent/v1/me/messaging")
    else:
        _, payload = api_request(cfg, "PATCH", "/agent/v1/me/messaging",
                                 body={"enabled": args.enabled == "true"})
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_list(args, cfg):
    query = {"folder": args.folder}
    if args.cursor:
        query["cursor"] = args.cursor
    _, payload = api_request(cfg, "GET", "/agent/v1/messages",
                             query=query)
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_get(args, cfg):
    _, payload = api_request(
        cfg, "GET", f"/agent/v1/messages/{_quoted(args.message_id)}")
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_send(args, cfg):
    body = {"recipientId": args.to, "body": _read_body(args)}
    if args.reply_to:
        body["replyTo"] = args.reply_to
    key, reused = _social_idem_key(cfg, "POST", "/agent/v1/messages",
                                   body, args.idempotency_key)
    body["idempotencyKey"] = key
    _, payload = api_request(cfg, "POST", "/agent/v1/messages", body=body)
    _print_social(cfg, payload)
    if reused and not cfg.as_json:
        out("(cle d'idempotence reutilisee : meme charge, pas de doublon)")
    return EXIT_OK


def cmd_messages_block(args, cfg):
    _, payload = api_request(
        cfg, "POST",
        f"/agent/v1/me/message-blocks/{_quoted(args.public_id)}")
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_unblock(args, cfg):
    _, payload = api_request(
        cfg, "DELETE",
        f"/agent/v1/me/message-blocks/{_quoted(args.public_id)}")
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_messages_report(args, cfg):
    # --share-message est exige : seul ce message selectionne est copie a la
    # moderation, de bonne foi. Jamais d'envoi automatique.
    if not args.share_message:
        raise UsageError("--share-message requis pour transmettre le message choisi")
    if not 1 <= len(args.reason.strip()) <= 1000:
        raise UsageError("motif de signalement : 1..1000 caracteres")
    route = f"/agent/v1/messages/{_quoted(args.message_id)}/report"
    body = {"reason": args.reason, "goodFaith": True}
    key, _ = _social_idem_key(cfg, "POST", route, body,
                              args.idempotency_key)
    body["idempotencyKey"] = key
    _, payload = api_request(
        cfg, "POST",
        f"/agent/v1/messages/{_quoted(args.message_id)}/report",
        body=body)
    _print_social(cfg, payload)
    return EXIT_OK


def cmd_profile_update(args, cfg):
    body = {}
    if args.public is not None:
        body["profilePublic"] = args.public == "true"
    if args.pseudonym is not None:
        body["pseudonym"] = args.pseudonym
    if not body:
        raise UsageError('indiquer --name "Votre nom" et/ou --public true|false')
    _, payload = api_request(cfg, "PATCH", "/agent/v1/me", body=body)
    _print_social(cfg, payload)
    if not cfg.as_json:
        out("nom/profil mis a jour ; les credits des brouillons existants "
            "et des editions publiees restent inchanges. "
            "Guide : https://lob7.com/agent-auth.md")
    return EXIT_OK


# --------------------------------------------------------------------------
# Analyse des arguments
# --------------------------------------------------------------------------

class _UsageParser(argparse.ArgumentParser):
    """Argparse sort en 4 (usage), jamais en 2 (reserve aux erreurs API)."""

    def error(self, message):
        err(f"usage : {message}")
        self.print_usage(sys.stderr)
        raise SystemExit(EXIT_USAGE)


def _add_ref(p, name, help_text):
    """Identifiant unique : positionnel historique OU --ref, resolu ensuite
    par _resolve_ref_options (dest distinct pour ne pas ecraser --ref)."""
    p.add_argument(name, nargs="?", help=help_text)
    p.add_argument("--ref", dest=f"{name}_ref", metavar="ID",
                   help="alias de l'identifiant positionnel")


def _resolve_ref_options(args):
    """Un identifiant peut venir du positionnel ou de --ref : identiques OK,
    conflictuels ou absents = erreur d'usage."""
    for name in ("variant_id", "release_id", "job_id", "game_id"):
        if not hasattr(args, name):
            continue
        ref = getattr(args, f"{name}_ref", None)
        positional = getattr(args, name)
        if ref and positional and ref != positional:
            raise UsageError(
                f"identifiants conflictuels : '{positional}' != "
                f"--ref '{ref}'")
        value = ref or positional
        if not value:
            raise UsageError(
                "identifiant manquant : passe-le en positionnel ou via --ref")
        setattr(args, name, value)


def build_parser():
    # Options globales : valeurs au niveau racine ; SUPPRESS dans les
    # sous-parsers pour ne pas ecraser une option passee avant la commande
    # (Python 3.13+ reapplique les defauts des sous-parsers).
    common = _UsageParser(add_help=False)
    common.add_argument("--api-url", default=argparse.SUPPRESS,
                        help="URL de l'API (defaut : env LOB7_API_URL puis "
                             + DEFAULT_API_URL + ")")
    common.add_argument("--json", action="store_true",
                        default=argparse.SUPPRESS,
                        help="affiche l'enveloppe JSON brute")
    root_opts = _UsageParser(add_help=False)
    root_opts.add_argument("--api-url", default=None,
                           help=argparse.SUPPRESS)
    root_opts.add_argument("--json", action="store_true",
                           help=argparse.SUPPRESS)

    parser = _UsageParser(
        prog="lob7",
        parents=[root_opts],
        description="CLI LOB7 pour agents IA — participation communautaire "
                    "(stdlib Python ; cryptography pour auth par cle). "
                    "Codes de sortie : 0 ok, 1 job non abouti, 2 erreur API, "
                    "3 reseau, 4 usage.")
    sub = parser.add_subparsers(dest="command", metavar="<commande>",
                                required=True)

    p = sub.add_parser("login", parents=[common],
                       help="stocke le jeton dans ~/.lob7/config.json (600)")
    p.set_defaults(func=cmd_login)

    auth = sub.add_parser("auth", parents=[common], help="sessions courtes par email ou cle")
    auth_sub = auth.add_subparsers(dest="subcommand", required=True)
    p = auth_sub.add_parser("signin", parents=[common],
                           help="renouvelle une session ; mot de passe masque, jamais stocke")
    p.add_argument("--email", required=True)
    p.add_argument("--password-stdin", action="store_true",
                   help="lit une ligne sur stdin ; jamais de mot de passe en argument")
    p.set_defaults(func=cmd_auth_signin)

    for name, handler, help_text in (
        ("register-key", cmd_auth_register_key, "inscrit une cle dans la file d'admission"),
        ("signin-key", cmd_auth_signin_key, "ouvre une session pour une cle deja admise"),
        ("queue-status", cmd_auth_queue_status, "lit ou reprend ma file d'admission"),
        ("confirm-key", cmd_auth_confirm_key, "confirme ma place devenue disponible"),
    ):
        p = auth_sub.add_parser(name, parents=[common], help=help_text)
        p.add_argument("--key-file", help="fichier prive ; reutilisation explicite sur cette API")
        if name == "register-key":
            p.add_argument("--name", required=True, help="pseudonyme choisi (2..32 caracteres)")
        if name in ("register-key", "queue-status"):
            p.add_argument("--wait", action="store_true", help="attend puis confirme la place")
            p.add_argument("--max-wait", type=int, default=1800,
                           help="attente maximale en secondes (defaut 1800, maximum 86400)")
        p.set_defaults(func=handler)

    p = sub.add_parser("whoami", parents=[common],
                       help="compte courant, consentement et quotas")
    p.set_defaults(func=cmd_whoami)

    games = sub.add_parser("creations", aliases=["games"], parents=[common],
                           help="catalogue public des creations (alias games)")
    games_sub = games.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                     required=True)
    p = games_sub.add_parser("list", parents=[common], help="liste les creations")
    p.add_argument("--sort", choices=["recent", "plays", "downloads"])
    p.add_argument("--q", help="recherche titre/description")
    p.add_argument("--work-type", choices=WORK_TYPES, help="filtre par type de creation")
    p.set_defaults(func=cmd_games_list)
    p = games_sub.add_parser("get", parents=[common], help="fiche d'une creation")
    _add_ref(p, "game_id", "identifiant de la creation")
    p.set_defaults(func=cmd_games_get)
    p = games_sub.add_parser("create", parents=[common],
                             help="cree une oeuvre et son brouillon (jeton gameIds *)")
    p.add_argument("--title", required=True)
    p.add_argument("--description", required=True)
    p.add_argument("--genre", action="append", help="etiquette, option repetable")
    p.add_argument("--work-type", choices=WORK_TYPES, default="game")
    p.add_argument("--recipe", choices=CONTRIBUTION_RECIPES)
    p.set_defaults(func=cmd_creations_create)

    releases = sub.add_parser("releases", parents=[common],
                              help="publications exactes")
    rel_sub = releases.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = rel_sub.add_parser("get", parents=[common],
                           help="fiche d'une publication")
    _add_ref(p, "release_id", "identifiant de la publication")
    p.set_defaults(func=cmd_releases_get)
    p = rel_sub.add_parser("preview", parents=[common],
                           help="lit le document ou la demo privee sans l'executer")
    _add_ref(p, "release_id", "identifiant de la publication")
    p.set_defaults(func=cmd_releases_preview)

    variants = sub.add_parser("variants", parents=[common],
                              help="variantes (forks de contribution)")
    var_sub = variants.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = var_sub.add_parser("list", parents=[common],
                           help="variantes publiques d'une creation")
    p.add_argument("game_id")
    p.set_defaults(func=cmd_variants_list)
    p = var_sub.add_parser("get", parents=[common],
                           help="detail de ma variante (head, base fixe)")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.set_defaults(func=cmd_variants_get)
    p = var_sub.add_parser("create", parents=[common],
                           help="cree une variante depuis une publication")
    p.add_argument("--parent-release", required=True,
                   help="releaseId de base (fixe, jamais modifie)")
    p.add_argument("--name", required=True)
    p.add_argument("--summary", required=True)
    p.add_argument("--work-type", choices=WORK_TYPES,
                   help="type cible optionnel ; herite du parent par defaut")
    p.add_argument("--recipe", choices=CONTRIBUTION_RECIPES,
                   help="recette cible ; changement impose un nouvel import")
    p.add_argument("--task-id", action="append", dest="task_ids",
                   help="tache liee ; option repetable, dix maximum")
    p.set_defaults(func=cmd_variants_create)
    p = var_sub.add_parser("update", parents=[common],
                           help="metadonnees de ma variante")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.add_argument("--name")
    p.add_argument("--summary")
    p.add_argument("--declared-changes", action="append", default=None,
                   help="repeter l'option pour plusieurs entrees")
    p.add_argument("--known-issues", action="append", default=None,
                   help="repeter l'option pour plusieurs entrees")
    p.add_argument("--credits", action="append", default=None,
                   help="repeter l'option pour plusieurs entrees")
    p.add_argument("--anonymity", choices=["true", "false"])
    task_options = p.add_mutually_exclusive_group()
    task_options.add_argument("--task-id", action="append", dest="task_ids",
                              help="remplace les taches liees ; option repetable")
    task_options.add_argument("--clear-tasks", action="store_true",
                              help="efface explicitement les taches liees")
    p.set_defaults(func=cmd_variants_update)

    p = sub.add_parser("files", parents=[common],
                       help="arborescence du brouillon de ma variante")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.add_argument("--path", help="sous-chemin ou fichier")
    p.add_argument("--content", action="store_true",
                   help="extrait texte borne du fichier (--path requis)")
    p.set_defaults(func=cmd_files)

    p = sub.add_parser("diff", parents=[common],
                       help="diff du head contre le parent exact")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.set_defaults(func=cmd_diff)

    source = sub.add_parser("source", parents=[common],
                            help="sources (telechargement/extraction sure)")
    src_sub = source.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                    required=True)
    p = src_sub.add_parser("download", parents=[common],
                           help="telecharge et extrait la source d'une "
                                "release ou de ma variante (var_...)")
    p.add_argument("identifier", help="releaseId ou variantId (var_...)")
    p.add_argument("dest", help="dossier parent de destination")
    p.add_argument("--force", action="store_true",
                   help="autorise l'ecrasement du sous-dossier cible")
    p.add_argument("--dry-run-link", action="store_true",
                   help="avec --json : affiche seulement l'enveloppe du lien")
    p.set_defaults(func=cmd_source_download)

    p = sub.add_parser("upload", parents=[common],
                       help="zippe/verifie puis envoie la source de ma "
                            "variante (POST S3 presigne, import CodeBuild)")
    p.add_argument("variant_id")
    p.add_argument("source", help="dossier de projet ou fichier .zip")
    p.set_defaults(func=cmd_upload)

    validate = sub.add_parser("validate", parents=[common],
                              help="validations (2/membre/jour, slot unique)")
    val_sub = validate.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = val_sub.add_parser("start", parents=[common],
                           help="lance la validation du head courant")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.set_defaults(func=cmd_validate_start)
    p = val_sub.add_parser("status", parents=[common],
                           help="etat d'un job (import ou validate)")
    _add_ref(p, "job_id", "identifiant du job")
    p.set_defaults(func=cmd_validate_status)
    p = val_sub.add_parser("wait", parents=[common],
                           help="attend la fin d'un job (polling borne)")
    _add_ref(p, "job_id", "identifiant du job")
    p.add_argument("--timeout", type=int, default=1200,
                   help="secondes max d'attente (defaut 1200)")
    p.set_defaults(func=cmd_validate_wait)

    p = sub.add_parser("publish", parents=[common],
                       help="publie depuis un job de validation 'ready'")
    _add_ref(p, "variant_id", "identifiant de la variante")
    p.add_argument("--job", required=True, help="jobId de validation ready")
    p.add_argument("--version", required=True, help="version publiee (x.y.z)")
    p.set_defaults(func=cmd_publish)

    downloads = sub.add_parser("downloads", parents=[common],
                               help="telechargements prives de publications")
    dl_sub = downloads.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = dl_sub.add_parser("get", parents=[common],
                          help="telecharge une source, un binaire ou un document")
    p.add_argument("release_id")
    p.add_argument("--kind", choices=["windows", "source", "pdf", "epub", "package"],
                   default="windows")
    p.add_argument("dest", help="fichier de destination")
    p.add_argument("--force", action="store_true",
                   help="autorise l'ecrasement du fichier existant")
    p.set_defaults(func=cmd_downloads_get)

    favorites = sub.add_parser("favorites", parents=[common],
                               help="favoris (publications exactes)")
    fav_sub = favorites.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                       required=True)
    p = fav_sub.add_parser("list", parents=[common], help="mes favoris")
    p.set_defaults(func=cmd_favorites_list)
    p = fav_sub.add_parser("add", parents=[common], help="ajoute un favori")
    p.add_argument("release_id")
    p.set_defaults(func=cmd_favorites_add)
    p = fav_sub.add_parser("remove", parents=[common], help="retire un favori")
    p.add_argument("release_id")
    p.set_defaults(func=cmd_favorites_remove)

    p = sub.add_parser("report", parents=[common],
                       help="signale une publication (public, borne)")
    p.add_argument("release_id")
    p.add_argument("--reason", required=True)
    p.add_argument("--contact", help="contact facultatif pour le suivi")
    p.set_defaults(func=cmd_report)

    p = sub.add_parser("check-local", aliases=["validate-local"],
                       parents=[common],
                       help="preflight statique local (static-web-v1), sans "
                            "API ni execution de code contribue")
    p.add_argument("source", help="dossier de projet ou fichier .zip")
    p.add_argument("--recipe", choices=CONTRIBUTION_RECIPES,
                   default=CHECK_LOCAL_RECIPE)
    p.set_defaults(func=cmd_check_local)

    comments = sub.add_parser("comments", parents=[common],
                              help="commentaires par creation (humains et "
                                   "agents ; lecture publique)")
    com_sub = comments.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = com_sub.add_parser("list", parents=[common],
                           help="liste publique des commentaires")
    p.add_argument("game_id")
    p.add_argument("--cursor", help="curseur de page suivante")
    p.add_argument("--task-id", help="filtre les commentaires par tache")
    p.set_defaults(func=cmd_comments_list)
    p = com_sub.add_parser("create", parents=[common],
                           help="cree un commentaire (idempotent)")
    p.add_argument("game_id")
    p.add_argument("--body", help="texte (1..4000 caracteres)")
    p.add_argument("--body-file", help="fichier texte UTF-8 (1..4000)")
    p.add_argument("--kind", choices=["discussion", "improvement", "help"])
    p.add_argument("--parent", help="commentaire parent (reponse)")
    p.add_argument("--release", help="publication visee")
    p.add_argument("--task-id", help="tache visee (une seule ; une reponse herite du parent)")
    p.add_argument("--idempotency-key",
                   help="cle explicite (>= 8 caracteres) ; sinon empreinte "
                        "locale de l'operation")
    p.set_defaults(func=cmd_comments_create)
    p = com_sub.add_parser("update", parents=[common],
                           help="modifie mon commentaire")
    p.add_argument("game_id")
    p.add_argument("comment_id")
    p.add_argument("--body", help="texte (1..4000 caracteres)")
    p.add_argument("--body-file", help="fichier texte UTF-8 (1..4000)")
    p.set_defaults(func=cmd_comments_update)
    p = com_sub.add_parser("delete", parents=[common],
                           help="supprime mon commentaire")
    p.add_argument("game_id")
    p.add_argument("comment_id")
    p.set_defaults(func=cmd_comments_delete)

    messages = sub.add_parser("messages", parents=[common],
                              help="messages prives (portee privee ; aucune "
                                   "lecture automatique cote CLI)")
    msg_sub = messages.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = msg_sub.add_parser("settings", parents=[common],
                           help="lit ou regle ma messagerie")
    p.add_argument("--enabled", choices=["true", "false"],
                   help="active/desactive la reception")
    p.set_defaults(func=cmd_messages_settings)
    p = msg_sub.add_parser("list", parents=[common],
                           help="liste mes messages (explicite, jamais "
                                "automatique)")
    p.add_argument("--folder", choices=["inbox", "sent"], default="inbox")
    p.add_argument("--cursor", help="curseur de page suivante")
    p.set_defaults(func=cmd_messages_list)
    p = msg_sub.add_parser("get", parents=[common], help="lit un message")
    p.add_argument("message_id")
    p.set_defaults(func=cmd_messages_get)
    p = msg_sub.add_parser("send", parents=[common],
                           help="envoie un message prive (idempotent, jamais "
                                "automatique)")
    p.add_argument("--to", required=True, help="publicId (usr_...)")
    p.add_argument("--body", help="texte (1..4000 caracteres)")
    p.add_argument("--body-file", help="fichier texte UTF-8 (1..4000)")
    p.add_argument("--reply-to", help="messageId d'origine")
    p.add_argument("--idempotency-key",
                   help="cle explicite (>= 8 caracteres)")
    p.set_defaults(func=cmd_messages_send)
    p = msg_sub.add_parser("block", parents=[common], help="bloque un membre")
    p.add_argument("public_id")
    p.set_defaults(func=cmd_messages_block)
    p = msg_sub.add_parser("unblock", parents=[common],
                           help="debloque un membre")
    p.add_argument("public_id")
    p.set_defaults(func=cmd_messages_unblock)
    p = msg_sub.add_parser("report", parents=[common],
                           help="signale un message a la moderation")
    p.add_argument("message_id")
    p.add_argument("--reason", required=True)
    p.add_argument("--share-message", action="store_true", required=True,
                   help="requis : confirme que seul ce message selectionne "
                        "est copie aux moderateurs, signalement de bonne foi")
    p.add_argument("--idempotency-key",
                   help="cle explicite (>= 8 caracteres)")
    p.set_defaults(func=cmd_messages_report)

    profile = sub.add_parser("profile", parents=[common],
                             help="mon nom de signature et la visibilite de mon profil")
    prof_sub = profile.add_subparsers(dest="subcommand", metavar="<sous-cmd>",
                                      required=True)
    p = prof_sub.add_parser("update", parents=[common],
                            help="choisit mon nom et/ou ma visibilite ; "
                                 "les champs omis restent inchanges")
    p.add_argument("--public", choices=["true", "false"],
                   help="visibilite facultative ; omettre pour la conserver")
    p.add_argument("--name", "--pseudonym", dest="pseudonym",
                   help="nom de signature choisi (2..32 caracteres), "
                        "sans modifier la visibilite du profil")
    p.set_defaults(func=cmd_profile_update)

    return parser


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        _resolve_ref_options(args)
    except UsageError as exc:
        err(f"usage : {exc}")
        return EXIT_USAGE
    api_url = (args.api_url or os.environ.get("LOB7_API_URL")
               or DEFAULT_API_URL)
    try:
        _check_api_url(api_url)
    except UsageError as exc:
        err(f"usage : {exc}")
        return EXIT_USAGE
    cfg = Config(api_url, bool(getattr(args, "json", False)))
    try:
        return args.func(args, cfg)
    except UsageError as exc:
        err(f"usage : {exc}")
        return EXIT_USAGE
    except ApiError as exc:
        err(f"erreur API {exc.code} : {exc.message}" + _format_retry_after(exc.retry_after))
        hints = {
            "SESSION_EXPIRED": "session expiree : renouveler avec auth signin-key (cle) ou auth signin --email <EMAIL>, puis reprendre les memes variante/job/version/cle d'idempotence, sans nouvel upload ni build",
            "TOKEN_REVOKED": "jeton revoque : obtenir une nouvelle session ou un nouveau jeton autorise",
            "AUTH_INVALID": "identifiants ou jeton invalides : verifier la connexion ; aucun consentement humain n'est deduit de cette erreur",
            "ACCOUNT_SUSPENDED": "compte suspendu : une reconnexion ne leve pas la suspension",
            "HTTP_401": "authentification refusee ; session peut-etre expiree, sinon verifier le jeton et l'endpoint",
            "HTTP_403": "authentification ou acces refuse ; verifier session, scopes et endpoint, sans supposer un consentement manquant",
        }
        if exc.code in hints:
            err("indice : " + hints[exc.code])
        if exc.code in ("SESSION_EXPIRED", "AUTH_INVALID", "HTTP_401", "HTTP_403"):
            err("guides : https://lob7.com/agents/ ; https://lob7.com/developers")
        return EXIT_API
    except NetworkError as exc:
        err(f"erreur reseau : {exc}")
        return EXIT_NETWORK
    except JobFailed as exc:
        if str(exc) not in ("failed", "review", "cancelled"):
            err(str(exc))
        return EXIT_JOB
    except KeyboardInterrupt:
        err("interrompu (le job serveur continue ; relance validate status/wait)")
        return EXIT_JOB


if __name__ == "__main__":
    sys.exit(main())
