"""Audit du raccordement sonore: qui est configure, qui est emis, qui manque.

Le piege que cet outil existe pour attraper: un evenement CONFIGURE dans les
donnees mais qu'aucun script n'emet jamais. Rien ne le signale — la page de
config le montre bien rempli, la publication l'accepte, et le jeu reste muet.
L'inverse existe aussi: un point d'accroche pose dans le code sans aucun son
derriere.

Trois listes en sortie:
  * MUET      configure, jamais emis  -> le son ne sortira jamais
  * ORPHELIN  emis, jamais configure  -> le hook existe, rien a jouer
  * OK        les deux

Usage: python tools/audio_wiring_audit.py [--markdown]
"""

from __future__ import annotations

import argparse
import io
import json
import re
import sys
from pathlib import Path

PROJECT = Path(__file__).resolve().parents[1]
CONTRACT = PROJECT / "data" / "audio_contract.json"
MASTER = PROJECT / "data" / "game_data_master.json"
SCAN_DIRS = ["scripts", "autoload"]

# Chaque raccourci du directeur et le prefixe qu'il ajoute a sa cle courte.
# Le premier argument n'est pas toujours un litteral nu: `play_player("death"
# if letal else "hurt", ...)` en porte DEUX. On prend donc tout ce qui est
# entre guillemets jusqu'a la virgule de tete, sinon l'audit declare muet un
# evenement pourtant bien emis — et on cherche alors un bug qui n'existe pas.
FIRST_ARG = {
    "play_event": "", "start_loop": "", "play_ui": "", "play_restaurant": "",
    "play_player": "player_", "play_player_attack": "player_",
    "play_enemy": "enemy_", "play_enemy_attack": "enemy_",
    "play_dungeon": "dungeon_",
}
# Les plus longs d'abord: sinon `play_player` avale le debut de
# `play_player_attack`, et le prefixe applique n'est plus le bon.
CALL = re.compile(r'\b(' + "|".join(sorted(FIRST_ARG, key=len, reverse=True)) + r')\(\s*([^,)]*)')
LITERAL = re.compile(r'"([a-z0-9_]+)"')
# Un argument qui n'est pas un litteral nu ne nomme pas un evenement: une cle
# construite (`"player_" + short_key`) est la mecanique d'un raccourci, et un
# appel (`str(node.get_meta("audio_event"))`) lit une cle posee ailleurs. Les
# retenir faisait apparaitre de faux evenements `player_` et `audio_event`.
NOT_A_LITERAL = ("+", "(")


def load(path: Path) -> dict:
    return json.loads(io.open(path, encoding="utf-8").read())


def emitted() -> dict[str, list[str]]:
    """Ou chaque evenement est emis, en lisant les scripts du jeu."""
    hits: dict[str, list[str]] = {}
    for folder in SCAN_DIRS:
        for path in sorted((PROJECT / folder).rglob("*.gd")):
            # Le directeur EST un point d'accroche: c'est lui qui sonorise les
            # boutons, le retour et la navigation pour tout le jeu. L'exclure
            # faisait declarer muets trois evenements pourtant bien emis.
            # Seul le controle, qui n'est pas du jeu, reste hors du compte.
            if path.name == "audio_check.gd":
                continue
            text = io.open(path, encoding="utf-8", errors="replace").read()
            for match in CALL.finditer(text):
                argument = match.group(2)
                if any(mark in argument for mark in NOT_A_LITERAL):
                    continue
                prefix = FIRST_ARG[match.group(1)]
                for literal in LITERAL.findall(argument):
                    hits.setdefault(prefix + literal, []).append(str(path.relative_to(PROJECT)))
    return hits


def configured() -> dict[str, list[str]]:
    """Ou chaque evenement recoit une configuration jouable."""
    master = load(MASTER)
    contract = load(CONTRACT)
    scopes = contract["scopes"]
    found: dict[str, list[str]] = {}

    def note(key: str, where: str) -> None:
        found.setdefault(key, []).append(where)

    def walk(scope: str, sounds, where: str) -> None:
        prefix = scopes[scope]["prefix"]
        for short, cue in (sounds or {}).items():
            if isinstance(cue, dict) and cue.get("mode") == "custom":
                note(prefix + short, where)

    for key, cue in ((master.get("audio_defaults") or {}).get("events") or {}).items():
        if isinstance(cue, dict) and cue.get("mode") == "custom":
            note(key, "défauts généraux")
    for pid, item in (master.get("player_configs") or {}).items():
        walk("player_configs", item.get("sounds"), f"joueur {pid}")
    for wid, item in (master.get("weapons") or {}).items():
        walk("weapons", item.get("sounds"), f"arme {wid}")
    for eid, enemy in (master.get("enemies") or {}).items():
        walk("enemies", enemy.get("sounds"), f"monstre {eid}")
        for index, attack in enumerate(enemy.get("attacks") or []):
            walk("enemy_attacks", attack.get("sounds"), f"{eid} attaque {index + 1}")
        for li, level in enumerate(enemy.get("levels") or []):
            walk("enemies", level.get("sounds"), f"{eid} niveau {li + 2}")
            for index, attack in enumerate(level.get("attacks") or []):
                walk("enemy_attacks", attack.get("sounds"), f"{eid} niv {li + 2} att {index + 1}")
    for bid, item in (master.get("biomes") or {}).items():
        walk("biomes", item.get("sounds"), f"biome {bid}")
    for rid, item in (master.get("rooms") or {}).items():
        walk("rooms", item.get("sounds"), f"salle {rid}")
    return found


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--markdown", action="store_true")
    options = parser.parse_args()

    contract = load(CONTRACT)
    events = contract["events"]
    fires = emitted()
    settings = configured()

    mute, orphan, ok = [], [], []
    for key in sorted(events):
        has_hook = key in fires
        has_config = key in settings
        if has_config and not has_hook:
            mute.append(key)
        elif has_hook and not has_config:
            orphan.append(key)
        elif has_hook and has_config:
            ok.append(key)

    unknown = sorted(set(fires) - set(events))

    if options.markdown:
        lines = ["# Raccordement sonore", "",
                 "Genere par `python tools/audio_wiring_audit.py --markdown`.", "",
                 f"- Emis **et** configures : **{len(ok)}**",
                 f"- Configures mais JAMAIS EMIS : **{len(mute)}** — le son ne sortira pas",
                 f"- Emis sans configuration : **{len(orphan)}** — le hook attend un son",
                 ""]
        if mute:
            lines += ["## Configures mais jamais emis", "",
                      "| Evenement | Configure dans |", "| --- | --- |"]
            lines += [f"| `{k}` — {events[k]['label']} | {', '.join(settings[k][:3])} |" for k in mute]
        if orphan:
            lines += ["", "## Emis sans configuration", "",
                      "| Evenement | Emis depuis |", "| --- | --- |"]
            lines += [f"| `{k}` — {events[k]['label']} | {', '.join(sorted(set(fires[k]))[:3])} |" for k in orphan]
        if unknown:
            lines += ["", "## Emis hors contrat (bug)", ""] + [f"- `{k}`" for k in unknown]
        out = PROJECT / "docs" / "audio" / "raccordement.md"
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text("\n".join(lines) + "\n", encoding="utf-8")
        print(f"-> {out}")

    print(f"OK (emis et configures) : {len(ok)}")
    print(f"MUET (configure, jamais emis) : {len(mute)}")
    for key in mute:
        print(f"   {key:34s} configure dans {', '.join(settings[key][:2])}")
    print(f"ORPHELIN (emis, jamais configure) : {len(orphan)}")
    for key in orphan:
        print(f"   {key:34s} emis depuis {', '.join(sorted(set(fires[key]))[:2])}")
    if unknown:
        print(f"HORS CONTRAT : {unknown}")
    # Un evenement configure mais jamais emis est une panne silencieuse: c'est
    # le seul cas qui fait echouer cet audit.
    return 1 if mute or unknown else 0


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