import argparse
import csv
import glob
import os
import re
import time
from collections import defaultdict


BUILD = "12.0.7.67808"
DOWNLOADS = r"C:\Users\corey\Downloads"
DATA_ADDON = os.path.abspath(os.path.join(os.getcwd(), "..", "CollectionLog_Data"))


def csv_path(table):
    return os.path.join(DOWNLOADS, f"{table}.{BUILD}.csv")


def read_csv(table):
    with open(csv_path(table), newline="", encoding="utf-8-sig") as f:
        return list(csv.DictReader(f))


def as_int(value, default=None):
    if value is None or value == "":
        return default
    try:
        return int(float(value))
    except ValueError:
        return default


def as_float(value, default=0.0):
    if value is None or value == "":
        return default
    try:
        return float(value)
    except ValueError:
        return default


def lua_string(value):
    value = "" if value is None else str(value)
    value = value.replace("\\", "\\\\")
    value = value.replace('"', '\\"')
    value = value.replace("\r", "")
    value = value.replace("\n", "\\n")
    return f'"{value}"'


def table_fields(fields):
    out = []
    for key, value in fields:
        if value is None:
            continue
        if isinstance(value, str):
            out.append(f"{key} = {lua_string(value)}")
        elif isinstance(value, list):
            out.append(f"{key} = {{ {', '.join(str(v) for v in value)} }}")
        else:
            out.append(f"{key} = {value}")
    return "{ " + ", ".join(out) + " }"


def write_file(path, text, dry_run):
    if dry_run:
        return
    with open(path, "w", encoding="utf-8", newline="\n") as f:
        f.write(text)


def check_lua_balance(path):
    text = open(path, encoding="utf-8").read()
    stack = []
    pairs = {"{": "}", "[": "]", "(": ")"}
    closers = {v: k for k, v in pairs.items()}
    in_string = None
    escape = False
    i = 0
    line = 1
    col = 0
    while i < len(text):
        ch = text[i]
        col += 1
        if ch == "\n":
            line += 1
            col = 0
            if in_string:
                raise ValueError(f"{path}:{line}: newline inside string")
            i += 1
            continue
        if in_string:
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == in_string:
                in_string = None
            i += 1
            continue
        if ch == "-" and i + 1 < len(text) and text[i + 1] == "-":
            while i < len(text) and text[i] != "\n":
                i += 1
            continue
        if ch in ("'", '"'):
            in_string = ch
            i += 1
            continue
        if ch in pairs:
            stack.append((ch, line, col))
        elif ch in closers:
            if not stack or stack[-1][0] != closers[ch]:
                raise ValueError(f"{path}:{line}:{col}: unmatched {ch}")
            stack.pop()
        i += 1
    if in_string:
        raise ValueError(f"{path}: unterminated string")
    if stack:
        ch, lno, cno = stack[-1]
        raise ValueError(f"{path}:{lno}:{cno}: unclosed {ch}")
    return True


def current_row_lines(path):
    if not os.path.exists(path):
        return {}
    rows = {}
    with open(path, encoding="utf-8") as f:
        for line in f:
            m = re.match(r"^  \[(\d+)\] = (\{.*\}),\s*$", line)
            if m:
                rows[int(m.group(1))] = line.rstrip("\n")
    return rows


def parse_existing_journal_names(path):
    names = {}
    addon_missing = []
    if not os.path.exists(path):
        return names, addon_missing
    text = open(path, encoding="utf-8").read()
    for jid, name in re.findall(r"^--\s+(\d+):\s+(.+)$", text, re.M):
        names[int(jid)] = name.strip()
    m = re.search(r"DB\.meta\.addonMissingInstanceIDs = \{ ([^}]*) \}", text)
    if m:
        addon_missing = [int(x) for x in re.findall(r"\d+", m.group(1))]
    return names, addon_missing


def load_item_sparse():
    items = {}
    for row in read_csv("ItemSparse"):
        item_id = as_int(row.get("ID"))
        if item_id:
            items[item_id] = {
                "name": row.get("Display_lang") or "",
                "description": row.get("Description_lang") or "",
                "quality": as_int(row.get("OverallQualityID"), 0),
                "classID": as_int(row.get("ClassID"), None),
                "subclassID": as_int(row.get("SubclassID"), None),
                "inventoryType": as_int(row.get("InventoryType"), None),
            }
    return items


def load_mounts():
    mounts = {}
    by_spell = {}
    by_name = defaultdict(list)
    for row in read_csv("Mount"):
        mount_id = as_int(row.get("ID"))
        if not mount_id:
            continue
        spell_id = as_int(row.get("SourceSpellID"), 0)
        data = {
            "name": row.get("Name_lang") or "",
            "spellID": spell_id,
            "sourceType": as_int(row.get("SourceTypeEnum"), 0),
            "sourceText": row.get("SourceText_lang") or "",
        }
        mounts[mount_id] = data
        if spell_id:
            by_spell[spell_id] = mount_id
        if data["name"]:
            by_name[normalize_name(data["name"])].append(mount_id)
    return mounts, by_spell, by_name


def load_toys():
    toys = {}
    for row in read_csv("Toy"):
        item_id = as_int(row.get("ItemID"))
        toy_id = as_int(row.get("ID"))
        if item_id and toy_id:
            toys[item_id] = {
                "toyID": toy_id,
                "sourceType": as_int(row.get("SourceTypeEnum"), 0),
                "sourceText": row.get("SourceText_lang") or "",
            }
    return toys


def load_pet_species():
    spells = {}
    for row in read_csv("SpellName"):
        spell_id = as_int(row.get("ID"))
        if spell_id:
            spells[spell_id] = row.get("Name_lang") or ""

    species = {}
    for row in read_csv("BattlePetSpecies"):
        species_id = as_int(row.get("ID"))
        if not species_id:
            continue
        spell_id = as_int(row.get("SummonSpellID"), 0)
        species[species_id] = {
            "spellID": spell_id,
            "name": spells.get(spell_id, ""),
            "sourceType": as_int(row.get("SourceTypeEnum"), 0),
            "sourceText": row.get("SourceText_lang") or "",
            "description": row.get("Description_lang") or "",
        }
    return species


def load_house_decor():
    decor = {}
    for row in read_csv("HouseDecor"):
        decor_id = as_int(row.get("ID"))
        item_id = as_int(row.get("ItemID"))
        if decor_id and item_id:
            decor[item_id] = {
                "decorID": decor_id,
                "decorName": row.get("Name_lang") or "",
                "type": row.get("Type") or "",
                "modelType": row.get("ModelType") or "",
            }
    return decor


def load_item_modified_appearances():
    by_item = defaultdict(lambda: {"appearanceIDs": set(), "modifiedAppearanceIDs": set()})
    by_mod = {}
    for row in read_csv("ItemModifiedAppearance"):
        mod_id = as_int(row.get("ID"))
        item_id = as_int(row.get("ItemID"))
        appearance_id = as_int(row.get("ItemAppearanceID"))
        if not (mod_id and item_id and appearance_id):
            continue
        by_item[item_id]["appearanceIDs"].add(appearance_id)
        by_item[item_id]["modifiedAppearanceIDs"].add(mod_id)
        by_mod[mod_id] = {"itemID": item_id, "appearanceID": appearance_id}
    return by_item, by_mod


def normalize_name(value):
    value = (value or "").lower()
    value = re.sub(r"[^a-z0-9]+", " ", value)
    return re.sub(r"\s+", " ", value).strip()


def datapack_lua_files():
    if not os.path.isdir(DATA_ADDON):
        return []
    return glob.glob(os.path.join(DATA_ADDON, "**", "*.lua"), recursive=True)


def parse_datapack_collectibles():
    item_ids = set()
    collectible_ids = {}
    collectible_types = {}
    type_vocab = set()
    for path in datapack_lua_files():
        text = open(path, encoding="utf-8").read()
        for item_id in re.findall(r"Hitem:(\d+)", text):
            item_id = int(item_id)
            if item_id > 1000:
                item_ids.add(item_id)
        for item_id in re.findall(r"\[(\d+)\]\s*=", text):
            item_id = int(item_id)
            if item_id > 1000:
                item_ids.add(item_id)
        for block in re.finditer(r"itemCollectibleIDs\s*=\s*\{(.*?)\n\s*\},", text, re.S):
            for key, value in re.findall(r"\[(\d+)\]\s*=\s*(-?\d+)", block.group(1)):
                collectible_ids[int(key)] = int(value)
        for block in re.finditer(r"itemCollectibleTypes\s*=\s*\{(.*?)\n\s*\},", text, re.S):
            for key, value in re.findall(r"\[(\d+)\]\s*=\s*\"([^\"]*)\"", block.group(1)):
                collectible_types[int(key)] = value
                type_vocab.add(value)
    return item_ids, collectible_ids, collectible_types, type_vocab


def gen_mount_db(mounts):
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionMountDB = ns.CompletionMountDB or {}",
        "local DB = ns.CompletionMountDB",
        "",
        f"-- Generated from Mount.{BUILD}.csv",
        "DB.byMountID = {",
    ]
    for mount_id in sorted(mounts):
        m = mounts[mount_id]
        lines.append("  [%d] = %s," % (
            mount_id,
            table_fields([
                ("name", m["name"]),
                ("spellID", m["spellID"]),
                ("sourceType", m["sourceType"]),
                ("sourceText", m["sourceText"]),
            ]),
        ))
    lines += [
        "}",
        "",
        "DB.bySpellID = DB.bySpellID or nil",
        "",
        "local function buildSpellIndex()",
        "  local out = {}",
        "  for mountID, data in pairs(DB.byMountID or {}) do",
        "    local spellID = tonumber(data and data.spellID)",
        "    if spellID and spellID > 0 then out[spellID] = mountID end",
        "  end",
        "  DB.bySpellID = out",
        "end",
        "",
        "function DB.GetByMountID(mountID)",
        "  mountID = tonumber(mountID)",
        "  return mountID and DB.byMountID and DB.byMountID[mountID] or nil",
        "end",
        "",
        "function DB.GetBySpellID(spellID)",
        "  spellID = tonumber(spellID)",
        "  if not spellID then return nil end",
        "  if not DB.bySpellID then buildSpellIndex() end",
        "  local mountID = DB.bySpellID and DB.bySpellID[spellID] or nil",
        "  return mountID and DB.byMountID and DB.byMountID[mountID] or nil, mountID",
        "end",
        "",
    ]
    return "\n".join(lines)


def gen_toy_db(toys, items):
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionToyItemDB = ns.CompletionToyItemDB or {}",
        "local DB = ns.CompletionToyItemDB",
        "",
        f"-- Generated from Toy.{BUILD}.csv + ItemSparse.{BUILD}.csv.",
        "DB.byItemID = {",
    ]
    for item_id in sorted(toys):
        t = toys[item_id]
        item = items.get(item_id, {})
        desc = item.get("description") or ""
        lines.append("  [%d] = %s," % (
            item_id,
            table_fields([
                ("toyID", t["toyID"]),
                ("sourceType", t["sourceType"]),
                ("sourceText", t["sourceText"]),
                ("itemName", item.get("name") or ""),
                ("description", desc if desc else None),
            ]),
        ))
    lines += [
        "}",
        "",
        "function DB.Get(itemID)",
        "  itemID = tonumber(itemID)",
        "  if not itemID then return nil end",
        "  return DB.byItemID and DB.byItemID[itemID] or nil",
        "end",
        "",
        "return DB",
        "",
    ]
    return "\n".join(lines)


def gen_pet_species_db(species):
    ids = sorted(species)
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionPetSpeciesDB = ns.CompletionPetSpeciesDB or {}",
        "local DB = ns.CompletionPetSpeciesDB",
        "",
        f"-- Generated from BattlePetSpecies.{BUILD}.csv.",
        "DB.ids = {",
    ]
    for i in range(0, len(ids), 12):
        chunk = ids[i:i + 12]
        lines.append("  " + ",   ".join(str(v) for v in chunk) + ",")
    lines += [
        "}",
        "",
        "function DB.IterateIDs()",
        "  return ipairs(DB.ids)",
        "end",
        "",
    ]
    return "\n".join(lines)


def row_for_mount_item(item_id, mount_id, mounts, items):
    mount = mounts.get(mount_id)
    item = items.get(item_id, {})
    if not mount:
        return None
    source_text = mount.get("sourceText") or ""
    source_type = mount.get("sourceType")
    return table_fields([
        ("itemName", item.get("name") or ""),
        ("name", mount.get("name") or ""),
        ("spellID", mount.get("spellID") or 0),
        ("mountID", mount_id),
        ("itemDescription", item.get("description") or ""),
        ("sourceText", source_text if source_text else None),
        ("sourceType", source_type if source_text else None),
    ])


def resolve_mount_item(item_id, ctype, cid, items, mounts, by_spell, by_name):
    if ctype in ("mount", "mountID"):
        return cid if cid in mounts else None
    if ctype == "mountSpellID":
        return by_spell.get(cid)
    item = items.get(item_id, {})
    desc = (item.get("description") or "").lower()
    if "mount" not in desc or "summon" not in desc:
        return None
    candidates = by_name.get(normalize_name(item.get("name") or ""), [])
    if len(candidates) == 1:
        return candidates[0]
    return None


def gen_mount_item_db(items, mounts, by_spell, by_name, dp_ids, dp_types, dp_collectibles):
    current = current_row_lines("CollectionLog_CompletionMountItemDB.lua")
    generated = {}
    candidate_ids = set(current)
    for item_id, ctype in dp_types.items():
        if ctype in ("mount", "mountID", "mountSpellID"):
            candidate_ids.add(item_id)
    for item_id in sorted(candidate_ids):
        mount_id = resolve_mount_item(
            item_id,
            dp_types.get(item_id),
            dp_collectibles.get(item_id),
            items,
            mounts,
            by_spell,
            by_name,
        )
        if mount_id:
            generated[item_id] = "  [%d] = %s," % (item_id, row_for_mount_item(item_id, mount_id, mounts, items))
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionMountItemDB = ns.CompletionMountItemDB or {}",
        "local DB = ns.CompletionMountItemDB",
        "",
        "-- Generated from raid/dungeon loot itemIDs cross-referenced against",
        f"-- ItemSparse.{BUILD}.csv and Mount.{BUILD}.csv.",
        "-- The primary goal is systematic itemID-first coverage for loot-table mount tokens,",
        "-- with a very small fallback layer for edge cases where item text is not specific enough.",
        "DB.byItemID = {",
    ]
    for item_id in sorted(set(current) | set(generated)):
        lines.append(generated.get(item_id) or current[item_id])
    lines += [
        "}",
        "",
        "function DB.Get(itemID)",
        "  itemID = tonumber(itemID)",
        "  return itemID and DB.byItemID and DB.byItemID[itemID] or nil",
        "end",
        "",
    ]
    return "\n".join(lines), generated


def row_for_pet_item(item_id, species_id, species, items):
    sp = species.get(species_id)
    item = items.get(item_id, {})
    if not sp:
        return None
    source_text = sp.get("sourceText") or ""
    return table_fields([
        ("itemName", item.get("name") or ""),
        ("name", sp.get("name") or ""),
        ("speciesID", species_id),
        ("spellID", sp.get("spellID") or 0),
        ("itemDescription", item.get("description") or ""),
        ("sourceText", source_text if source_text else None),
        ("sourceType", sp.get("sourceType") if source_text else None),
    ])


def is_pet_type(ctype):
    ctype = (ctype or "").lower()
    return "pet" in ctype or "species" in ctype


def gen_pet_item_db(items, species, dp_types, dp_collectibles):
    current = current_row_lines("CollectionLog_CompletionPetItemDB.lua")
    generated = {}
    candidate_ids = set(current)
    for item_id, ctype in dp_types.items():
        if is_pet_type(ctype):
            candidate_ids.add(item_id)
    for item_id in sorted(candidate_ids):
        ctype = dp_types.get(item_id)
        cid = dp_collectibles.get(item_id)
        species_id = cid if is_pet_type(ctype) and cid in species else None
        if species_id:
            generated[item_id] = "  [%d] = %s," % (item_id, row_for_pet_item(item_id, species_id, species, items))
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionPetItemDB = ns.CompletionPetItemDB or {}",
        "local DB = ns.CompletionPetItemDB",
        "",
        "-- Generated from raid/dungeon loot itemIDs cross-referenced against",
        f"-- ItemSparse.{BUILD}.csv, BattlePetSpecies.{BUILD}.csv, and SpellName.{BUILD}.csv.",
        "-- This is the itemID-first pet mapping layer for stable backend pet definitions.",
        "DB.byItemID = {",
    ]
    for item_id in sorted(set(current) | set(generated)):
        lines.append(generated.get(item_id) or current[item_id])
    lines += [
        "}",
        "",
        "function DB.Get(itemID)",
        "  itemID = tonumber(itemID)",
        "  return itemID and DB.byItemID and DB.byItemID[itemID] or nil",
        "end",
        "",
    ]
    return "\n".join(lines), generated


def row_for_housing_item(item_id, decor, items):
    d = decor.get(item_id)
    item = items.get(item_id, {})
    if not d:
        return None
    return table_fields([
        ("decorID", d["decorID"]),
        ("decorName", d["decorName"]),
        ("itemName", item.get("name") or d["decorName"]),
        ("description", item.get("description") or ""),
        ("type", d["type"]),
        ("modelType", d["modelType"]),
    ])


def gen_housing_item_db(items, decor, dp_ids):
    current = current_row_lines("CollectionLog_CompletionHousingItemDB.lua")
    candidate_ids = (set(dp_ids) & set(decor)) | set(current)
    generated = {}
    for item_id in sorted(candidate_ids):
        if item_id in decor:
            generated[item_id] = "  [%d] = %s," % (item_id, row_for_housing_item(item_id, decor, items))
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionHousingItemDB = ns.CompletionHousingItemDB or {}",
        "local DB = ns.CompletionHousingItemDB",
        "",
        f"-- Generated from HouseDecor.{BUILD}.csv + ItemSparse.{BUILD}.csv, filtered to itemIDs present in raid/dungeon loot datapacks.",
        "DB.byItemID = {",
    ]
    for item_id in sorted(set(current) | set(generated)):
        lines.append(generated.get(item_id) or current[item_id])
    lines += [
        "}",
        "",
        "function DB.Get(itemID)",
        "  itemID = tonumber(itemID)",
        "  if not itemID then return nil end",
        "  return DB.byItemID and DB.byItemID[itemID] or nil",
        "end",
        "",
        "function DB.IterateIDs()",
        "  local ids = {}",
        "  for itemID in pairs(DB.byItemID or {}) do ids[#ids + 1] = itemID end",
        "  table.sort(ids)",
        "  local i = 0",
        "  return function()",
        "    i = i + 1",
        "    if ids[i] then return ids[i] end",
        "  end",
        "end",
        "",
    ]
    return "\n".join(lines), generated


def gen_housing_decor_pack(decor):
    lines = [
        "-- CollectionLog_DataPack_HousingDecor.lua",
        "local ADDON, ns = ...",
        "",
        f"-- DB2-derived mapping: itemID -> housing decor recordID (HouseDecor.{BUILD}.csv)",
        "-- Used to power the Housing tab and authoritative collected state via C_HousingCatalog.",
        "ns.HousingDecorByItemID = ns.HousingDecorByItemID or {}",
        "",
        "local t = {",
    ]
    for item_id in sorted(decor):
        lines.append(f"  [{item_id}] = {decor[item_id]['decorID']},")
    lines += [
        "}",
        "",
        "for itemID, decorID in pairs(t) do",
        "  ns.HousingDecorByItemID[itemID] = decorID",
        "end",
        "",
    ]
    return "\n".join(lines)


def gen_appearance_item_db(by_item, dp_ids):
    current = current_row_lines("CollectionLog_CompletionAppearanceItemDB.lua")
    scan_ids = set(dp_ids) | set(current)
    generated_ids = sorted(set(by_item) & scan_ids)
    lines = [
        "local ADDON, ns = ...",
        "",
        "ns.CompletionAppearanceItemDB = ns.CompletionAppearanceItemDB or {}",
        "local DB = ns.CompletionAppearanceItemDB",
        "",
        "DB.meta = {",
        "  version = 2,",
        f"  generatedAt = {int(time.time())},",
        f"  source = 'ItemModifiedAppearance.{BUILD}.csv + CollectionLog_Data Hitem scan',",
        f"  datapackItemCount = {len(scan_ids)},",
        f"  mappedItemCount = {len(generated_ids)},",
        "}",
        "",
        "DB.byItemID = {",
    ]
    for item_id in generated_ids:
        data = by_item[item_id]
        appearances = sorted(data["appearanceIDs"])
        mods = sorted(data["modifiedAppearanceIDs"])
        fields = [
            ("appearanceID", appearances[0] if appearances else None),
            ("appearanceIDs", appearances),
            ("modifiedAppearanceIDs", mods),
            ("source", "generated_datapack_scan"),
        ]
        lines.append("  [%d] = %s," % (item_id, table_fields(fields)))
    lines += [
        "}",
        "",
        "function DB.Get(itemID)",
        "  itemID = tonumber(itemID)",
        "  if not itemID then return nil end",
        "  return DB.byItemID[itemID]",
        "end",
        "",
    ]
    return "\n".join(lines), generated_ids


def gen_journal_instance_entrance():
    rows = read_csv("JournalInstanceEntrance")
    names, addon_missing = parse_existing_journal_names(os.path.join("Data", "JournalInstanceEntrance.lua"))
    grouped = defaultdict(list)
    for row in rows:
        jid = as_int(row.get("JournalInstanceID"))
        if not jid:
            continue
        grouped[jid].append({
            "db2ID": as_int(row.get("ID"), 0),
            "mapID": as_int(row.get("MapID"), 0),
            "areaID": as_int(row.get("AreaTableID"), 0),
            "faction": as_int(row.get("Faction"), 0),
            "wx": as_float(row.get("Position_0")),
            "wy": as_float(row.get("Position_1")),
            "wz": as_float(row.get("Position_2")),
        })
    duplicates = [jid for jid, entries in sorted(grouped.items()) if len(entries) > 1]
    lines = [
        f"-- Auto-generated from JournalInstanceEntrance DB2 export (build {BUILD})",
        "-- Verified mapping layer for Collection Log.",
        "-- Key: JournalInstanceID (Encounter Journal instanceID).",
        "-- IMPORTANT: some instances have multiple valid entrance rows; preserve them as arrays.",
        "",
        "CollectionLog_JournalInstanceEntrance = CollectionLog_JournalInstanceEntrance or {}",
        "local DB = CollectionLog_JournalInstanceEntrance",
        "DB.entries = DB.entries or {}",
        "DB.meta = DB.meta or {}",
        f'DB.meta.build = "{BUILD}"',
        f"DB.meta.sourceRowCount = {len(rows)}",
        f"DB.meta.uniqueInstanceCount = {len(grouped)}",
        "DB.meta.duplicateInstanceIDs = { " + ", ".join(str(v) for v in duplicates) + " }",
        "DB.meta.addonMissingInstanceIDs = { " + ", ".join(str(v) for v in addon_missing) + " }",
        "",
    ]
    for jid in sorted(grouped):
        lines.append(f"-- {jid}: {names.get(jid, 'JournalInstance ' + str(jid))}")
        lines.append(f"DB.entries[{jid}] = {{")
        for e in sorted(grouped[jid], key=lambda v: v["db2ID"]):
            lines.append(
                "  { db2ID = %d, mapID = %d, areaID = %d, faction = %d, wx = %.3f, wy = %.3f, wz = %.3f },"
                % (e["db2ID"], e["mapID"], e["areaID"], e["faction"], e["wx"], e["wy"], e["wz"])
            )
        lines.append("}")
        lines.append("")
    return "\n".join(lines)


def gen_appearance_sets(by_mod):
    groups = {}
    for row in read_csv("TransmogSetGroup"):
        group_id = as_int(row.get("ID"))
        if group_id:
            groups[group_id] = row.get("Name_lang") or ""

    set_rows = {}
    for row in read_csv("TransmogSet"):
        set_id = as_int(row.get("ID"))
        if set_id:
            set_rows[set_id] = row

    entries = defaultdict(dict)
    order = defaultdict(dict)
    for row in read_csv("TransmogSetItem"):
        set_id = as_int(row.get("TransmogSetID"))
        mod_id = as_int(row.get("ItemModifiedAppearanceID"))
        row_id = as_int(row.get("ID"), 0)
        ima = by_mod.get(mod_id)
        if not (set_id and mod_id and ima):
            continue
        key = (ima["itemID"], ima["appearanceID"])
        bucket = entries[set_id].setdefault(key, set())
        bucket.add(mod_id)
        order[set_id][key] = min(order[set_id].get(key, row_id or mod_id), row_id or mod_id)

    lines = [
        "local ADDON, ns = ...",
        "ns.AppearanceCanonicalData = {",
        "  groupNames = {",
    ]
    for group_id in sorted(groups):
        lines.append(f"    [{group_id}] = {lua_string(groups[group_id])},")
    lines += [
        "  },",
        "  sets = {",
    ]
    for set_id in sorted(set_rows):
        row = set_rows[set_id]
        lines.append(
            "    [%d] = { classMask = %d, groupID = %d, parentSetID = %d, expansionID = %d, uiOrder = %d, name = %s, entries = {"
            % (
                set_id,
                as_int(row.get("ClassMask"), 0) or 0,
                as_int(row.get("TransmogSetGroupID"), 0) or 0,
                as_int(row.get("ParentTransmogSetID"), 0) or 0,
                as_int(row.get("ExpansionID"), 0) or 0,
                as_int(row.get("UiOrder"), 0) or 0,
                lua_string(row.get("Name_lang") or ""),
            )
        )
        keys = sorted(entries.get(set_id, {}), key=lambda k: (order[set_id].get(k, 0), k[0], k[1]))
        for item_id, appearance_id in keys:
            mods = sorted(entries[set_id][(item_id, appearance_id)])
            lines.append(
                "      { appearanceID = %d, itemID = %d, modIDs = { %s } },"
                % (appearance_id, item_id, ", ".join(str(v) for v in mods))
            )
        lines.append("    } },")
    lines += [
        "  },",
        "}",
        "",
    ]
    return "\n".join(lines), len(set_rows), sum(len(v) for v in entries.values())


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--check", action="store_true")
    args = parser.parse_args()

    output_paths = [
        "CollectionLog_CompletionMountDB.lua",
        "CollectionLog_CompletionToyItemDB.lua",
        "CollectionLog_CompletionPetSpeciesDB.lua",
        "CollectionLog_CompletionMountItemDB.lua",
        "CollectionLog_CompletionPetItemDB.lua",
        "CollectionLog_CompletionHousingItemDB.lua",
        "CollectionLog_DataPack_HousingDecor.lua",
        "CollectionLog_CompletionAppearanceItemDB.lua",
        os.path.join("Data", "JournalInstanceEntrance.lua"),
        "CollectionLog_AppearanceSetsData.lua",
    ]

    if args.check:
        for path in output_paths:
            check_lua_balance(path)
        print(f"checked_lua_files={len(output_paths)}")
        return

    items = load_item_sparse()
    mounts, mounts_by_spell, mounts_by_name = load_mounts()
    toys = load_toys()
    species = load_pet_species()
    decor = load_house_decor()
    appearances_by_item, appearances_by_mod = load_item_modified_appearances()
    dp_ids, dp_collectibles, dp_types, type_vocab = parse_datapack_collectibles()

    mount_item_text, mount_items = gen_mount_item_db(items, mounts, mounts_by_spell, mounts_by_name, dp_ids, dp_types, dp_collectibles)
    pet_item_text, pet_items = gen_pet_item_db(items, species, dp_types, dp_collectibles)
    housing_item_text, housing_items = gen_housing_item_db(items, decor, dp_ids)
    appearance_item_text, appearance_item_ids = gen_appearance_item_db(appearances_by_item, dp_ids)
    appearance_sets_text, set_count, set_entry_count = gen_appearance_sets(appearances_by_mod)

    outputs = {
        "CollectionLog_CompletionMountDB.lua": gen_mount_db(mounts),
        "CollectionLog_CompletionToyItemDB.lua": gen_toy_db(toys, items),
        "CollectionLog_CompletionPetSpeciesDB.lua": gen_pet_species_db(species),
        "CollectionLog_CompletionMountItemDB.lua": mount_item_text,
        "CollectionLog_CompletionPetItemDB.lua": pet_item_text,
        "CollectionLog_CompletionHousingItemDB.lua": housing_item_text,
        "CollectionLog_DataPack_HousingDecor.lua": gen_housing_decor_pack(decor),
        "CollectionLog_CompletionAppearanceItemDB.lua": appearance_item_text,
        os.path.join("Data", "JournalInstanceEntrance.lua"): gen_journal_instance_entrance(),
        "CollectionLog_AppearanceSetsData.lua": appearance_sets_text,
    }

    for path, text in outputs.items():
        write_file(path, text, args.dry_run)

    print(f"dry_run={args.dry_run}")
    print(f"datapack_item_keys={len(dp_ids)}")
    print("datapack_collectible_types=" + ", ".join(sorted(type_vocab)))
    print(f"mounts={len(mounts)}")
    print(f"toys={len(toys)}")
    print(f"pet_species={len(species)}")
    print(f"mount_item_rows={len(set(current_row_lines('CollectionLog_CompletionMountItemDB.lua')) | set(mount_items))} generated_or_updated={len(mount_items)}")
    print(f"pet_item_rows={len(set(current_row_lines('CollectionLog_CompletionPetItemDB.lua')) | set(pet_items))} generated_or_updated={len(pet_items)}")
    print(f"housing_item_rows={len(set(current_row_lines('CollectionLog_CompletionHousingItemDB.lua')) | set(housing_items))} generated_or_updated={len(housing_items)}")
    print(f"housing_decor_map={len(decor)}")
    print(f"appearance_item_rows={len(appearance_item_ids)}")
    print(f"appearance_sets={set_count} set_entries={set_entry_count}")


if __name__ == "__main__":
    main()
