#!/usr/bin/env python3
"""
chattranslate_companion.py

Liest die Übersetzungs-Warteschlange aus der ChatTranslate-Addon-SavedVariables-
Datei, übersetzt offline mit Argos Translate (Pivot über Englisch, falls kein
direktes Sprachpaar installiert ist) und schreibt die Ergebnisse zurück in
dieselbe Datei. WoW liest SavedVariables nur beim Addon-Laden -- die
Übersetzungen werden im Spiel deshalb erst nach einem `/reload` sichtbar.

Voraussetzung: pip install argostranslate
Beim ersten Lauf für ein neues Sprachpaar wird das passende Modell aus dem
Argos-Paketindex heruntergeladen (braucht Internet). Danach läuft alles
offline.

Aufruf:
    python chattranslate_companion.py "C:\\...\\World of Warcraft\\_retail_"
    python chattranslate_companion.py /pfad/zu/_retail_ --account MeinAccount
    python chattranslate_companion.py /pfad/zu/_retail_ --watch
"""

from __future__ import annotations

import argparse
import glob
import logging
import os
import re
import sys
import time

DB_NAME = "ChatTranslateDB"


# ============================================================
# Lua-String-(De)Eskapierung -- bewusst nur das, was Blizzards
# eigener SavedVariables-Serializer tatsächlich erzeugt
# (Backslash, doppelte Anführungszeichen, Newline). Kein
# generischer Lua-String-Parser.
# ============================================================
def lua_unescape(s: str) -> str:
    return s.replace('\\"', '"').replace("\\n", "\n").replace("\\\\", "\\")


def lua_escape(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")


# ============================================================
# Klammer- und String-bewusster Block-Finder. Bewusst kein
# generischer Lua-Parser -- eng auf das Ausgabeformat dieses
# Addons zugeschnitten (siehe README, Abschnitt "Grenzen").
# ============================================================
def _find_matching_brace(content: str, open_idx: int) -> int:
    """open_idx zeigt auf '{'. Liefert den Index der passenden '}',
    ohne Klammern zu zählen, die innerhalb eines String-Literals liegen."""
    depth = 0
    in_string = False
    i = open_idx
    n = len(content)
    while i < n:
        c = content[i]
        if in_string:
            if c == "\\":
                i += 2
                continue
            if c == '"':
                in_string = False
        else:
            if c == '"':
                in_string = True
            elif c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
                if depth == 0:
                    return i
        i += 1
    raise ValueError("Keine passende schließende Klammer gefunden (Datei beschädigt oder Format unerwartet)")


def find_block(content: str, key: str):
    """Findet ["key"] = { ... } im Top-Level-Bereich der Datei.
    Liefert (open_idx, close_idx, inner_text) oder None, wenn der
    Schlüssel nicht existiert. open_idx/close_idx zeigen auf die
    äußeren geschweiften Klammern selbst (inklusive)."""
    m = re.search(r'\[\s*"%s"\s*\]\s*=\s*\{' % re.escape(key), content)
    if not m:
        return None
    open_idx = content.index("{", m.start())
    close_idx = _find_matching_brace(content, open_idx)
    return open_idx, close_idx, content[open_idx + 1 : close_idx]


def split_entries(inner: str):
    """Zerlegt den Inhalt eines Blocks in einzelne ["hash"] = { ... }
    Sub-Einträge. Liefert eine Liste von (hash, entry_inner_text)."""
    entries = []
    for m in re.finditer(r'\[\s*"([^"]+)"\s*\]\s*=\s*\{', inner):
        open_idx = inner.index("{", m.start())
        close_idx = _find_matching_brace(inner, open_idx)
        entries.append((m.group(1), inner[open_idx + 1 : close_idx]))
    return entries


def extract_field(entry_text: str, field: str):
    """Ein eigener Regex pro Feld, unabhängig von der Reihenfolge der
    Felder im Eintrag -- WoW garantiert keine feste Schlüsselreihenfolge."""
    m = re.search(r'\[\s*"%s"\s*\]\s*=\s*"((?:[^"\\]|\\.)*)"' % re.escape(field), entry_text)
    if not m:
        return None
    return lua_unescape(m.group(1))


# ============================================================
# Parsing der beiden relevanten Tabellen
# ============================================================
def parse_queue(content: str) -> dict:
    """-> { hash: {"text":, "srcLang":, "targetLang":} }"""
    block = find_block(content, "queue")
    if not block:
        return {}
    _, _, inner = block
    result = {}
    for hash_key, entry_text in split_entries(inner):
        text = extract_field(entry_text, "text")
        src = extract_field(entry_text, "srcLang")
        tgt = extract_field(entry_text, "targetLang")
        if text is not None and src is not None and tgt is not None:
            result[hash_key] = {"text": text, "srcLang": src, "targetLang": tgt}
    return result


def parse_translations(content: str) -> dict:
    """-> { hash: übersetzter_text }"""
    block = find_block(content, "translations")
    if not block:
        return {}
    _, _, inner = block
    result = {}
    for m in re.finditer(r'\[\s*"([^"]+)"\s*\]\s*=\s*"((?:[^"\\]|\\.)*)"', inner):
        result[m.group(1)] = lua_unescape(m.group(2))
    return result


# ============================================================
# Zurückschreiben -- ersetzt nur den Inhalt des translations-Blocks,
# der Rest der Datei bleibt byteidentisch erhalten.
# ============================================================
def _render_braced(entries: dict) -> str:
    lines = ["{"]
    for hash_key in sorted(entries):
        lines.append('\t\t["%s"] = "%s",' % (hash_key, lua_escape(entries[hash_key])))
    lines.append("\t}")
    return "\n".join(lines)


def write_translations(content: str, entries: dict) -> str:
    new_braced = _render_braced(entries)
    block = find_block(content, "translations")

    if block:
        open_idx, close_idx, _ = block
        return content[:open_idx] + new_braced + content[close_idx + 1 :]

    # Kein translations-Block vorhanden. Sollte wegen des Lua-Defaults
    # (ChatTranslateDB.translations = {}) eigentlich nicht passieren --
    # defensiver Fallback: direkt vor dem Ende des DB-Blocks einfügen.
    db_match = re.search(r"%s\s*=\s*\{" % re.escape(DB_NAME), content)
    if not db_match:
        raise ValueError(f"Kein {DB_NAME}-Block in der Datei gefunden -- ist das wirklich die ChatTranslate.lua?")
    db_open = content.index("{", db_match.start())
    db_close = _find_matching_brace(content, db_open)
    insertion = '\t["translations"] = ' + new_braced + ",\n"
    return content[:db_close] + insertion + content[db_close:]


# ============================================================
# Datei-Discovery
# ============================================================
def resolve_wtf_root(wow_path: str) -> str:
    direct = os.path.join(wow_path, "WTF")
    if os.path.isdir(direct):
        return direct
    nested = os.path.join(wow_path, "_retail_", "WTF")
    if os.path.isdir(nested):
        return nested
    raise FileNotFoundError(
        f"Kein WTF-Ordner unter '{wow_path}' oder '{wow_path}/_retail_' gefunden. "
        "wow_path sollte auf den _retail_-Ordner zeigen, oder direkt auf den Ordner, der WTF/ enthält."
    )


def find_saved_variables_path(wow_path: str, account: str | None = None) -> str:
    wtf_root = resolve_wtf_root(wow_path)

    if account:
        path = os.path.join(wtf_root, "Account", account, "SavedVariables", "ChatTranslate.lua")
        if not os.path.isfile(path):
            raise FileNotFoundError(
                f"'{path}' existiert nicht. Im Spiel das Addon laden und einmal /reload "
                "ausführen, damit WoW die SavedVariables-Datei anlegt."
            )
        return path

    pattern = os.path.join(wtf_root, "Account", "*", "SavedVariables", "ChatTranslate.lua")
    matches = glob.glob(pattern)
    if not matches:
        raise FileNotFoundError(
            f"Keine ChatTranslate.lua unter '{pattern}' gefunden. "
            "Addon im Spiel laden und einmal /reload ausführen, damit WoW die Datei anlegt."
        )
    if len(matches) > 1:
        matches.sort(key=os.path.getmtime, reverse=True)
        logging.warning(
            "Mehrere Accounts gefunden, nehme den zuletzt geänderten: %s "
            "(mit --account <NAME> einen bestimmten Account fest auswählen)",
            matches[0],
        )
    return matches[0]


# ============================================================
# Argos Translate -- Paketverwaltung und Übersetzung
# ============================================================
def ensure_packages(pairs_needed: set) -> None:
    """Installiert fehlende Argos-Pakete für jedes (src, tgt) Paar.
    Versucht zuerst das direkte Paar, fällt sonst auf Pivot über
    Englisch zurück. Muss VOLLSTÄNDIG abgeschlossen sein, bevor
    irgendetwas aus argostranslate.translate aufgerufen wird --
    get_installed_languages() cached sein Ergebnis beim ersten Aufruf."""
    import argostranslate.package as package

    try:
        package.update_package_index()
    except Exception as e:
        logging.warning("Paketindex konnte nicht aktualisiert werden (%s) -- nutze lokal zwischengespeicherten Index.", e)

    installed = {(p.from_code, p.to_code) for p in package.get_installed_packages()}

    def ensure_pair(src: str, tgt: str) -> None:
        if src == tgt or (src, tgt) in installed:
            return
        logging.info("Installiere Argos-Paket %s -> %s ...", src, tgt)
        if package.install_package_for_language_pair(src, tgt):
            installed.add((src, tgt))
            return
        if src != "en" and tgt != "en":
            logging.info("Kein direktes Paket %s -> %s, installiere Pivot über Englisch.", src, tgt)
            ensure_pair(src, "en")
            ensure_pair("en", tgt)
        else:
            logging.error("Kein Argos-Paket für %s -> %s im Index verfügbar.", src, tgt)

    for src, tgt in sorted(pairs_needed):
        ensure_pair(src, tgt)


def translate_entries(pending: dict) -> dict:
    """pending: { hash: {"text":, "srcLang":, "targetLang":} } für Einträge,
    die noch keine Übersetzung haben. -> { hash: übersetzter_text }
    für die, die erfolgreich übersetzt wurden."""
    import argostranslate.translate as translate

    results = {}
    for hash_key, entry in pending.items():
        src, tgt, text = entry["srcLang"], entry["targetLang"], entry["text"]
        if src == tgt:
            results[hash_key] = text
            continue
        try:
            results[hash_key] = translate.translate(text, src, tgt)
        except Exception as e:
            logging.error("Übersetzung fehlgeschlagen für [%s] (%s -> %s): %s", hash_key, src, tgt, e)
    return results


# ============================================================
# Ein Durchlauf: lesen, übersetzen, zurückschreiben
# ============================================================
def run_once(sv_path: str) -> int:
    with open(sv_path, "r", encoding="utf-8") as f:
        content = f.read()

    queue = parse_queue(content)
    translations = parse_translations(content)

    pending = {h: e for h, e in queue.items() if h not in translations}
    if not pending:
        logging.info("Warteschlange leer -- nichts zu tun.")
        return 0

    logging.info("%d neue Nachricht(en) in der Warteschlange.", len(pending))

    pairs_needed = {(e["srcLang"], e["targetLang"]) for e in pending.values() if e["srcLang"] != e["targetLang"]}
    if pairs_needed:
        ensure_packages(pairs_needed)

    new_translations = translate_entries(pending)
    if not new_translations:
        logging.warning("Keine Übersetzung erfolgreich -- Datei bleibt unverändert.")
        return 1

    translations.update(new_translations)
    new_content = write_translations(content, translations)

    with open(sv_path, "w", encoding="utf-8") as f:
        f.write(new_content)

    logging.info(
        "%d Übersetzung(en) zurückgeschrieben. Im Spiel '/reload' ausführen, um sie zu sehen.",
        len(new_translations),
    )
    return 0


# ============================================================
# CLI
# ============================================================
def main() -> int:
    parser = argparse.ArgumentParser(
        description="ChatTranslate-Begleitskript: übersetzt die Warteschlange offline mit Argos Translate."
    )
    parser.add_argument(
        "wow_path",
        help=r"Pfad zum WoW-Versionsordner (z.B. '...\World of Warcraft\_retail_') "
        "oder direkt zum Ordner, der WTF/ enthält.",
    )
    parser.add_argument("--account", default=None, help="Account-Ordnername unter WTF/Account/, falls mehrere vorhanden sind.")
    parser.add_argument("--watch", action="store_true", help="Alle --interval Sekunden erneut prüfen, statt einmalig zu laufen.")
    parser.add_argument("--interval", type=int, default=30, help="Poll-Intervall in Sekunden für --watch (Standard: 30).")
    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

    try:
        sv_path = find_saved_variables_path(args.wow_path, args.account)
    except FileNotFoundError as e:
        logging.error(str(e))
        return 1

    logging.info("Verwende SavedVariables-Datei: %s", sv_path)

    if not args.watch:
        return run_once(sv_path)

    logging.info("Watch-Modus: prüfe alle %ds. Mit Strg+C beenden.", args.interval)
    try:
        while True:
            try:
                run_once(sv_path)
            except Exception as e:
                logging.error("Fehler in diesem Durchlauf: %s", e)
            time.sleep(args.interval)
    except KeyboardInterrupt:
        logging.info("Beendet.")
    return 0


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