#!/usr/bin/env python3
"""
Import a Sirus-style Generated_EncounterJournal.lua dump into the
mod-encounter-journal custom_ej_* tables.

The dump holds seven top-level Lua tables that map 1:1 to our schema:

    JOURNALINSTANCE        -> custom_ej_instance
    JOURNALENCOUNTER       -> custom_ej_encounter
    JOURNALENCOUNTERCREATURE -> custom_ej_creature
    JOURNALENCOUNTERSECTION  -> custom_ej_section
    JOURNALENCOUNTERITEM     -> custom_ej_loot
    JOURNALTIER              -> custom_ej_tier
    JOURNALTIERXINSTANCE     -> custom_ej_tier_instance

The script emits a single .sql file (or writes to stdout) that uses
REPLACE INTO so re-running is safe. Pipe it into your MySQL container:

    python3 import_donor_journal.py -i Generated_EncounterJournal.lua \
        -o donor_import.sql
    docker exec -i ac-database mysql -uroot -p$PASS acore_world \
        < donor_import.sql

Each row from the dump goes through a small tokenizer that understands
quoted strings ("..."), long-bracket strings ([[...]]), numbers, and
nested braces. The dump only uses single-line strings, so no multi-line
handling is needed.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path
from typing import Any, Iterable, List, Optional, TextIO, Tuple

# ---------------------------------------------------------------------------
# Tokeniser / value parser
# ---------------------------------------------------------------------------


class LuaParseError(RuntimeError):
    pass


def _skip_ws(src: str, pos: int) -> int:
    while pos < len(src) and src[pos] in " \t\r\n,":
        pos += 1
    return pos


def _parse_quoted_string(src: str, pos: int) -> Tuple[str, int]:
    assert src[pos] == '"'
    pos += 1
    out: List[str] = []
    while pos < len(src):
        c = src[pos]
        if c == "\\" and pos + 1 < len(src):
            nxt = src[pos + 1]
            # Lua escapes: \", \', \\, \n, \r, \t — keep verbatim semantics.
            out.append({
                '"': '"', "'": "'", "\\": "\\",
                "n": "\n", "r": "\r", "t": "\t",
            }.get(nxt, nxt))
            pos += 2
            continue
        if c == '"':
            return "".join(out), pos + 1
        out.append(c)
        pos += 1
    raise LuaParseError("unterminated quoted string")


def _parse_long_bracket_string(src: str, pos: int) -> Tuple[str, int]:
    assert src.startswith("[[", pos)
    pos += 2
    end = src.find("]]", pos)
    if end < 0:
        raise LuaParseError("unterminated long-bracket string")
    return src[pos:end], end + 2


_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")


def _parse_number(src: str, pos: int) -> Tuple[float, int]:
    m = _NUM_RE.match(src, pos)
    if not m:
        raise LuaParseError(f"expected number at {pos}: {src[pos:pos+20]!r}")
    txt = m.group(0)
    val: Any = float(txt) if ("." in txt or "e" in txt or "E" in txt) else int(txt)
    return val, m.end()


def _parse_table(src: str, pos: int) -> Tuple[list, int]:
    """Parses `{ v1, v2, ... }` and returns the list of values."""
    if src[pos] != "{":
        raise LuaParseError(f"expected '{{' at {pos}")
    pos += 1
    out: list = []
    while True:
        pos = _skip_ws(src, pos)
        if pos >= len(src):
            raise LuaParseError("unterminated table literal")
        if src[pos] == "}":
            return out, pos + 1
        value, pos = _parse_value(src, pos)
        out.append(value)


def _parse_value(src: str, pos: int) -> Tuple[Any, int]:
    pos = _skip_ws(src, pos)
    c = src[pos]
    if c == '"':
        return _parse_quoted_string(src, pos)
    if src.startswith("[[", pos):
        return _parse_long_bracket_string(src, pos)
    if c == "{":
        return _parse_table(src, pos)
    return _parse_number(src, pos)


# ---------------------------------------------------------------------------
# Top-level table extractor
# ---------------------------------------------------------------------------


def _find_top_level_tables(text: str) -> dict:
    """
    Returns {table_name: dict_or_list}. Iterates the source once, splitting
    at top-level `NAME = {` declarations and parsing the brace-balanced body.
    """
    tables: dict = {}
    pos = 0
    pat = re.compile(r"^([A-Z_]+)\s*=\s*\{", re.MULTILINE)
    while True:
        m = pat.search(text, pos)
        if not m:
            break
        name = m.group(1)
        # Find brace-balanced end.
        body_start = m.end() - 1   # position of '{'
        depth = 0
        i = body_start
        in_str = None  # '"' or '[' for long bracket
        while i < len(text):
            ch = text[i]
            if in_str == '"':
                if ch == "\\":
                    i += 2
                    continue
                if ch == '"':
                    in_str = None
                i += 1
                continue
            if in_str == "[":
                if text.startswith("]]", i):
                    in_str = None
                    i += 2
                    continue
                i += 1
                continue
            if ch == '"':
                in_str = '"'
                i += 1
                continue
            if text.startswith("[[", i):
                in_str = "["
                i += 2
                continue
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    body = text[body_start:i + 1]
                    tables[name] = _parse_top_level_body(name, body)
                    pos = i + 1
                    break
            i += 1
        else:
            raise LuaParseError(f"unterminated table {name}")
    return tables


def _parse_top_level_body(name: str, body: str) -> Any:
    """
    Parses the contents of one top-level table, which can have either
    keyed entries `[id] = { ... }` or sequential entries `{ ... }`.
    Returns dict for keyed entries, list for sequential.
    """
    assert body.startswith("{") and body.endswith("}")
    inner = body[1:-1]
    i = 0
    keyed: dict = {}
    seq: list = []
    while True:
        i = _skip_ws(inner, i)
        if i >= len(inner):
            break
        if inner[i] == "[":
            # `[key] = value`
            j = inner.index("]", i)
            key_val, _ = _parse_value(inner, i + 1)
            i = j + 1
            i = _skip_ws(inner, i)
            if inner[i] != "=":
                raise LuaParseError(f"{name}: expected '=' at {i}")
            i = _skip_ws(inner, i + 1)
            value, i = _parse_value(inner, i)
            keyed[key_val] = value
        else:
            value, i = _parse_value(inner, i)
            seq.append(value)
    if keyed and seq:
        raise LuaParseError(f"{name}: mix of keyed/sequential entries")
    return keyed if keyed else seq


# ---------------------------------------------------------------------------
# SQL emission
# ---------------------------------------------------------------------------


def _sql_str(s: Any) -> str:
    s = str(s)
    return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"


def _to_int(v: Any, default: int = 0) -> int:
    if v is None:
        return default
    if isinstance(v, bool):
        return int(v)
    return int(v)


def _to_uint(v: Any) -> int:
    """Sirus uses -1 as 'all difficulties'; our schema is UNSIGNED → store 0."""
    n = _to_int(v)
    return n if n >= 0 else 0


def _to_signed(v: Any) -> int:
    return _to_int(v)


def _to_float(v: Any) -> float:
    if v is None:
        return 0.0
    return float(v)


def _load_id_set(path: Optional[Path]) -> Optional[set]:
    """Reads one id per line, returns a set of ints. None when path is None."""
    if path is None:
        return None
    out: set = set()
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            try:
                out.add(int(line))
            except ValueError:
                pass
    return out


def emit(tables: dict, out: TextIO,
         known_maps: Optional[set] = None,
         known_creatures: Optional[set] = None,
         known_items: Optional[set] = None,
         dropped: Optional[dict] = None) -> dict:
    """
    Writes REPLACE INTO statements for every donor table. Returns per-table
    insert counts for the run summary.

    When @known_maps/@known_creatures/@known_items are provided, rows that
    reference an id missing from the corresponding set are skipped:
      - instance with map_id not in known_maps is dropped (and all of its
        encounters/creatures/sections/loot/tier_instance go with it);
      - creature whose creature_id != 0 and not in known_creatures dropped;
      - loot whose item_id not in known_items dropped;
      - tier with no surviving tier_instance entries dropped.
    """
    counts = {
        "tier": 0, "instance": 0, "tier_instance": 0,
        "encounter": 0, "creature": 0, "section": 0, "loot": 0,
    }
    if dropped is None:
        dropped = {}
    for k in ("instance", "creature", "loot", "tier"):
        dropped.setdefault(k, 0)

    out.write("-- Generated by mod-encounter-journal/tools/"
              "import_donor_journal.py.\n")
    out.write("-- Source: Sirus-style Generated_EncounterJournal.lua dump.\n")
    out.write("-- Safe to re-run: every INSERT uses REPLACE semantics on PK.\n")
    out.write("SET NAMES utf8mb4;\n")
    out.write("START TRANSACTION;\n\n")

    # First pass: figure out which instance_ids survive the map filter so we
    # can transitively drop their encounters/creatures/sections/loot.
    surviving_instances: set = set()
    for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
        if len(row) < 12:
            row = row + [0] * (12 - len(row))
        map_id = _to_int(row[6])
        if known_maps is not None and map_id not in known_maps:
            dropped["instance"] += 1
            continue
        surviving_instances.add(_to_int(inst_id))

    # encounter_id -> instance_id (used by creature/section/loot filters)
    encounter_to_instance: dict = {}
    for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
        for row in rows:
            if len(row) < 12:
                continue
            encounter_to_instance[_to_int(row[0])] = _to_int(inst_id)

    def _enc_survives(enc_id: int) -> bool:
        inst = encounter_to_instance.get(enc_id)
        return inst is not None and inst in surviving_instances

    # ---- tiers ----
    # Build the set of tiers that retain at least one surviving instance
    # via JOURNALTIERXINSTANCE so we don't write orphan tiers.
    surviving_tiers: set = set()
    for inst_id, tirow in tables.get("JOURNALTIERXINSTANCE", {}).items():
        if _to_int(inst_id) in surviving_instances and len(tirow) >= 1:
            surviving_tiers.add(_to_int(tirow[0]))

    for row in tables.get("JOURNALTIER", []):
        # {id, name, flags}
        tid, name, flags = row[0], row[1], row[2] if len(row) > 2 else 0
        if surviving_tiers and _to_int(tid) not in surviving_tiers:
            dropped["tier"] += 1
            continue
        out.write(
            "REPLACE INTO `custom_ej_tier` "
            "(`id`, `name`, `flags`, `sort_order`) VALUES "
            f"({_to_int(tid)}, {_sql_str(name)}, {_to_int(flags)}, "
            f"{_to_int(tid)});\n"
        )
        counts["tier"] += 1
    out.write("\n")

    # ---- instances ----
    for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
        if _to_int(inst_id) not in surviving_instances:
            continue
        if len(row) < 12:
            row = row + [0] * (12 - len(row))
        (name, desc, btn, sbtn, bg, lbg, map_id, area, sort_o,
         flags, src_id, wmaa) = row[:12]
        out.write(
            "REPLACE INTO `custom_ej_instance` "
            "(`id`, `name`, `description`, `button_icon`, "
            "`small_button_icon`, `background`, `lore_background`, "
            "`map_id`, `area_id`, `world_map_area_id`, `flags`, `sort_order`) "
            "VALUES ("
            f"{_to_int(inst_id)}, {_sql_str(name)}, {_sql_str(desc)}, "
            f"{_sql_str(btn)}, {_sql_str(sbtn)}, {_sql_str(bg)}, "
            f"{_sql_str(lbg)}, {_to_int(map_id)}, {_to_int(area)}, "
            f"{_to_int(wmaa)}, {_to_int(flags)}, {_to_int(sort_o)}"
            ");\n"
        )
        counts["instance"] += 1
    out.write("\n")

    # ---- tier_instance ----
    # JOURNALTIERXINSTANCE[instance_id] = {tier_id, sort_order}
    for inst_id, row in tables.get("JOURNALTIERXINSTANCE", {}).items():
        if _to_int(inst_id) not in surviving_instances:
            continue
        tier_id = row[0]
        sort_o = row[1] if len(row) > 1 else 0
        out.write(
            "REPLACE INTO `custom_ej_tier_instance` "
            "(`tier_id`, `instance_id`, `sort_order`) VALUES "
            f"({_to_int(tier_id)}, {_to_int(inst_id)}, {_to_int(sort_o)});\n"
        )
        counts["tier_instance"] += 1
    out.write("\n")

    # ---- encounters ----
    # JOURNALENCOUNTER[instance_id] = list of rows
    #   {id, name, desc, map_x, map_y, floor, world_map_area, first_section,
    #    instance_id, difficulty_mask, flags, sort_order}
    for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
        if _to_int(inst_id) not in surviving_instances:
            continue
        for row in rows:
            if len(row) < 12:
                row = row + [0] * (12 - len(row))
            (eid, name, desc, mx, my, floor, wmaa, first_sec, src_inst,
             diff, flags, sort_o) = row[:12]
            out.write(
                "REPLACE INTO `custom_ej_encounter` "
                "(`id`, `instance_id`, `name`, `description`, "
                "`map_position_x`, `map_position_y`, `floor_index`, "
                "`world_map_area_id`, `first_section_id`, `difficulty_mask`, "
                "`flags`, `sort_order`) VALUES ("
                f"{_to_int(eid)}, {_to_int(inst_id)}, {_sql_str(name)}, "
                f"{_sql_str(desc)}, {_to_float(mx):.6f}, {_to_float(my):.6f}, "
                f"{_to_uint(floor)}, {_to_uint(wmaa)}, {_to_int(first_sec)}, "
                f"{_to_uint(diff)}, {_to_uint(flags)}, {_to_int(sort_o)}"
                ");\n"
            )
            counts["encounter"] += 1
    out.write("\n")

    # ---- creatures ----
    # JOURNALENCOUNTERCREATURE[encounter_id] = list of rows
    #   {name, desc, display_id, icon, encounter_id, sort, id, creature_id,
    #    difficulty_mask}
    for enc_id, rows in tables.get("JOURNALENCOUNTERCREATURE", {}).items():
        if not _enc_survives(_to_int(enc_id)):
            continue
        for row in rows:
            if len(row) < 9:
                row = row + [0] * (9 - len(row))
            (name, desc, display_id, icon, src_enc, sort_o, cid, cr_id,
             diff) = row[:9]
            cr_id_i = _to_int(cr_id)
            if (known_creatures is not None and cr_id_i != 0
                    and cr_id_i not in known_creatures):
                dropped["creature"] += 1
                continue
            out.write(
                "REPLACE INTO `custom_ej_creature` "
                "(`id`, `encounter_id`, `creature_id`, `name`, `description`, "
                "`creature_display_id`, `icon`, `difficulty_mask`, "
                "`sort_order`) VALUES ("
                f"{_to_int(cid)}, {_to_int(enc_id)}, {_to_int(cr_id)}, "
                f"{_sql_str(name)}, {_sql_str(desc)}, {_to_uint(display_id)}, "
                f"{_sql_str(icon)}, {_to_uint(diff)}, {_to_int(sort_o)}"
                ");\n"
            )
            counts["creature"] += 1
    out.write("\n")

    # ---- sections ----
    # JOURNALENCOUNTERSECTION[section_id] = single row
    #   {id, name, desc, display_id, desc_spell, icon_spell, encounter_id,
    #    next, sub, parent, flags, icon_flags, sort, type, difficulty,
    #    creature_id}
    for sid, row in tables.get("JOURNALENCOUNTERSECTION", {}).items():
        if len(row) < 16:
            row = row + [0] * (16 - len(row))
        (src_sid, name, desc, display_id, desc_spell, icon_spell, enc_id,
         next_sid, sub_sid, parent_sid, flags, icon_flags, sort_o, stype,
         diff, cr_id) = row[:16]
        if not _enc_survives(_to_int(enc_id)):
            continue
        out.write(
            "REPLACE INTO `custom_ej_section` "
            "(`id`, `encounter_id`, `parent_section_id`, `next_section_id`, "
            "`sub_section_id`, `name`, `description`, `creature_display_id`, "
            "`description_spell_id`, `icon_spell_id`, `icon`, `flags`, "
            "`icon_flags`, `sort_order`, `type`, `difficulty_mask`, "
            "`creature_id`) VALUES ("
            f"{_to_int(sid)}, {_to_int(enc_id)}, {_to_int(parent_sid)}, "
            f"{_to_int(next_sid)}, {_to_int(sub_sid)}, {_sql_str(name)}, "
            f"{_sql_str(desc)}, {_to_uint(display_id)}, "
            f"{_to_uint(desc_spell)}, {_to_uint(icon_spell)}, NULL, "
            f"{_to_uint(flags)}, {_to_uint(icon_flags)}, {_to_int(sort_o)}, "
            f"{_to_uint(stype)}, {_to_uint(diff)}, {_to_uint(cr_id)}"
            ");\n"
        )
        counts["section"] += 1
    out.write("\n")

    # ---- loot ----
    # JOURNALENCOUNTERITEM[encounter_id] = list of rows
    #   {item_id, encounter_id, difficulty_mask, faction_mask, flags, id,
    #    class_mask}
    for enc_id, rows in tables.get("JOURNALENCOUNTERITEM", {}).items():
        if not _enc_survives(_to_int(enc_id)):
            continue
        for row in rows:
            if len(row) < 7:
                row = row + [0] * (7 - len(row))
            (item_id, src_enc, diff, faction, flags, lid, class_mask) = row[:7]
            item_i = _to_int(item_id)
            if known_items is not None and item_i not in known_items:
                dropped["loot"] += 1
                continue
            out.write(
                "REPLACE INTO `custom_ej_loot` "
                "(`id`, `encounter_id`, `item_id`, `difficulty_mask`, "
                "`faction_mask`, `class_mask`, `flags`, `sort_order`) "
                "VALUES ("
                f"{_to_int(lid)}, {_to_int(enc_id)}, {_to_int(item_id)}, "
                f"{_to_uint(diff)}, {_to_signed(faction)}, "
                f"{_to_signed(class_mask)}, {_to_uint(flags)}, "
                f"{_to_int(lid)}"
                ");\n"
            )
            counts["loot"] += 1
    out.write("\n")

    out.write("COMMIT;\n")
    return counts


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--input", "-i", required=True, type=Path,
                   help="Generated_EncounterJournal.lua")
    p.add_argument("--output", "-o", type=Path,
                   help="Where to write the .sql file (default: stdout)")
    p.add_argument("--known-maps", type=Path,
                   help="File with allowed map ids (one per line). Donor "
                        "instances whose map_id is not listed are dropped, "
                        "together with their encounters/creatures/sections/"
                        "loot.")
    p.add_argument("--known-creatures", type=Path,
                   help="File with allowed creature_template entries. Donor "
                        "creature cards with creature_id not in the list are "
                        "dropped.")
    p.add_argument("--known-items", type=Path,
                   help="File with allowed item_template entries. Donor loot "
                        "rows referencing missing items are dropped.")
    args = p.parse_args()

    text = args.input.read_text(encoding="utf-8")
    tables = _find_top_level_tables(text)

    print("parsed tables:", file=sys.stderr)
    for name, body in tables.items():
        size = len(body) if isinstance(body, (dict, list)) else "?"
        print(f"  {name:30s} {size}", file=sys.stderr)

    known_maps = _load_id_set(args.known_maps)
    known_creatures = _load_id_set(args.known_creatures)
    known_items = _load_id_set(args.known_items)

    if known_maps is not None:
        print(f"map whitelist: {len(known_maps)} ids", file=sys.stderr)
    if known_creatures is not None:
        print(f"creature whitelist: {len(known_creatures)} ids",
              file=sys.stderr)
    if known_items is not None:
        print(f"item whitelist: {len(known_items)} ids", file=sys.stderr)

    dropped: dict = {}
    if args.output:
        with args.output.open("w", encoding="utf-8") as f:
            counts = emit(tables, f, known_maps, known_creatures,
                          known_items, dropped)
    else:
        counts = emit(tables, sys.stdout, known_maps, known_creatures,
                      known_items, dropped)

    print("emitted rows:", file=sys.stderr)
    for k, v in counts.items():
        print(f"  {k:14s} {v}", file=sys.stderr)
    if any(dropped.values()):
        print("dropped (custom Sirus entries):", file=sys.stderr)
        for k, v in dropped.items():
            if v:
                print(f"  {k:14s} {v}", file=sys.stderr)
    return 0


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