#!/usr/bin/env python3
"""从本地 K2 Lua 源快照生成 Markdown 与 CSV 配方表。"""

from __future__ import annotations

import argparse
import csv
import hashlib
import io
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path


HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCE_DIR = ROOT / "old" / "中间文件" / "recipe_sources" / "k2"
ZH_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2so" / "Krastorio2-zh-CN.cfg"
EN_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2" / "Krastorio2-en.cfg"
MD_PATH = HERE / "K2配方表.md"
CSV_PATH = HERE / "K2配方表.csv"

CATEGORY_ZH = {
    "advanced-chemistry": "高级化学",
    "air-purification": "空气净化",
    "atmosphere-condensation": "大气冷凝",
    "bioprocessing": "生物处理",
    "centrifuging": "离心处理",
    "chemistry": "化学",
    "crafting": "制造",
    "crafting-with-fluid": "带流体制造",
    "crushing": "粉碎",
    "electrolysis": "电解",
    "fluid-filtration": "流体过滤",
    "fuel-refinery": "燃料精炼",
    "growing": "培育",
    "matter-deconversion": "物质逆转换",
    "nuclear-fusion": "核聚变",
    "oil-processing": "石油处理",
    "smelting": "冶炼",
    "smelting-crafting": "冶炼制造",
    "stabilizer-charging": "稳定器充能",
    "t3-tech-cards": "三级科技卡",
}


@dataclass(frozen=True)
class Stack:
    kind: str
    name: str
    amount: str
    probability: str | None = None
    temperature: str | None = None


@dataclass(frozen=True)
class Recipe:
    source_file: str
    source_line: int
    category: str
    recipe_id: str
    energy_required: str
    enabled: bool
    ingredients: tuple[Stack, ...]
    results: tuple[Stack, ...]


def mask_strings_and_comments(text: str) -> str:
    """屏蔽字符串与注释内容，同时保留字符位置和换行。"""
    chars = list(text)
    i = 0
    state = "code"
    quote = ""
    while i < len(chars):
        c = chars[i]
        n = chars[i + 1] if i + 1 < len(chars) else ""
        if state == "code":
            if c == "-" and n == "-":
                chars[i] = chars[i + 1] = " "
                i += 2
                state = "comment"
                continue
            if c in {'"', "'"}:
                quote = c
                chars[i] = " "
                i += 1
                state = "string"
                continue
        elif state == "comment":
            if c == "\n":
                state = "code"
            else:
                chars[i] = " "
            i += 1
            continue
        elif state == "string":
            if c == "\\":
                chars[i] = " "
                if i + 1 < len(chars):
                    chars[i + 1] = " "
                i += 2
                continue
            if c == quote:
                chars[i] = " "
                state = "code"
            elif c != "\n":
                chars[i] = " "
            i += 1
            continue
        i += 1
    return "".join(chars)


def brace_pairs(text: str) -> dict[int, int]:
    masked = mask_strings_and_comments(text)
    stack: list[int] = []
    pairs: dict[int, int] = {}
    for i, c in enumerate(masked):
        if c == "{":
            stack.append(i)
        elif c == "}" and stack:
            pairs[stack.pop()] = i
    return pairs


def containing_block(text: str, position: int) -> tuple[int, int]:
    pairs = brace_pairs(text)
    candidates = [(start, end) for start, end in pairs.items() if start < position < end]
    if not candidates:
        raise ValueError(f"找不到位置 {position} 所属的 Lua 表")
    return min(candidates, key=lambda pair: pair[1] - pair[0])


def extract_table(block: str, field: str) -> str:
    match = re.search(rf"\b{re.escape(field)}\s*=\s*\{{", block)
    if not match:
        raise ValueError(f"配方缺少 {field} 表")
    opening = block.find("{", match.start())
    closing = brace_pairs(block).get(opening)
    if closing is None:
        raise ValueError(f"{field} 表的大括号不配对")
    return block[opening : closing + 1]


def scalar(block: str, field: str, default: str | None = None) -> str:
    match = re.search(rf"\b{re.escape(field)}\s*=\s*([^,\n}}]+)", block)
    if not match:
        if default is None:
            raise ValueError(f"配方缺少字段 {field}")
        return default
    return match.group(1).strip().strip('"\'')


def parse_stacks(table: str) -> tuple[Stack, ...]:
    stacks: list[Stack] = []
    for match in re.finditer(r"\{([^{}]*)\}", table, flags=re.S):
        entry = match.group(1)
        name_match = re.search(
            r'\bname\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))',
            entry,
        )
        if not name_match:
            continue
        kind_match = re.search(r'\btype\s*=\s*"([^"]+)"', entry)
        amount_match = re.search(r"\bamount\s*=\s*([0-9.]+)", entry)
        min_match = re.search(r"\bamount_min\s*=\s*([0-9.]+)", entry)
        max_match = re.search(r"\bamount_max\s*=\s*([0-9.]+)", entry)
        probability_match = re.search(r"\bprobability\s*=\s*([0-9.]+)", entry)
        temperature_match = re.search(r"\btemperature\s*=\s*([0-9.]+)", entry)
        if amount_match:
            amount = amount_match.group(1)
        elif min_match and max_match:
            amount = f"{min_match.group(1)}–{max_match.group(1)}"
        else:
            amount = "1"
        stacks.append(
            Stack(
                kind=kind_match.group(1) if kind_match else "item",
                name=name_match.group(1) or name_match.group(2),
                amount=amount,
                probability=probability_match.group(1) if probability_match else None,
                temperature=temperature_match.group(1) if temperature_match else None,
            )
        )
    return tuple(stacks)


def parse_recipe_file(path: Path) -> list[Recipe]:
    text = path.read_text(encoding="utf-8-sig")
    recipes: list[Recipe] = []
    seen_blocks: set[tuple[int, int]] = set()
    for match in re.finditer(r'\btype\s*=\s*"recipe"', text):
        start, end = containing_block(text, match.start())
        if (start, end) in seen_blocks:
            continue
        seen_blocks.add((start, end))
        block = text[start : end + 1]
        recipe_id = scalar(block, "name")
        recipes.append(
            Recipe(
                source_file=path.name,
                source_line=text.count("\n", 0, start) + 1,
                category=scalar(block, "category", "crafting"),
                recipe_id=recipe_id,
                energy_required=scalar(block, "energy_required", "0.5"),
                enabled=scalar(block, "enabled", "true").lower() == "true",
                ingredients=parse_stacks(extract_table(block, "ingredients")),
                results=parse_stacks(extract_table(block, "results")),
            )
        )
    return recipes


def parse_locale(path: Path) -> dict[str, dict[str, str]]:
    sections: dict[str, dict[str, str]] = {}
    current = ""
    for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
        line = raw_line.strip()
        if not line or line.startswith(";") or line.startswith("#"):
            continue
        if line.startswith("[") and line.endswith("]"):
            current = line[1:-1]
            sections.setdefault(current, {})
            continue
        if "=" in line and current:
            key, value = line.split("=", 1)
            sections[current][key] = value
    return sections


def expand_macros(value: str, locale: dict[str, dict[str, str]]) -> str:
    macro_sections = {"ITEM": "item-name", "FLUID": "fluid-name", "ENTITY": "entity-name"}
    pattern = re.compile(r"__(ITEM|FLUID|ENTITY)__([^_][^_]*)__")
    for _ in range(5):
        replaced = pattern.sub(
            lambda m: locale.get(macro_sections[m.group(1)], {}).get(m.group(2), m.group(2)),
            value,
        )
        if replaced == value:
            break
        value = replaced
    return value


def localized_name(
    name: str,
    kind: str,
    locale: dict[str, dict[str, str]],
    *,
    recipe: bool = False,
) -> str | None:
    if recipe:
        candidates = ("recipe-name", "item-name", "fluid-name", "entity-name")
    elif kind == "fluid":
        candidates = ("fluid-name", "item-name", "recipe-name")
    else:
        candidates = ("item-name", "entity-name", "recipe-name", "fluid-name")
    for section in candidates:
        value = locale.get(section, {}).get(name)
        if value:
            return expand_macros(value, locale)
    return None


def source_digest(paths: list[Path]) -> str:
    digest = hashlib.sha256()
    for path in paths:
        digest.update(path.name.encode("utf-8"))
        digest.update(b"\0")
        digest.update(path.read_bytes())
        digest.update(b"\0")
    return digest.hexdigest()


def escape_md(value: str) -> str:
    return value.replace("|", "\\|").replace("\n", " ")


def story_duration_hours(game_seconds: str) -> str:
    """按全书统一比例把游戏配方秒数换算为正文小时数。"""
    try:
        return f"{float(game_seconds):g}小时"
    except ValueError:
        return f"{game_seconds}小时（待运行时解析）"


def story_duration_value(game_seconds: str) -> str:
    """供 CSV 使用的正文小时数；数值与游戏秒数保持一比一。"""
    try:
        return f"{float(game_seconds):g}"
    except ValueError:
        return game_seconds


def display_name(
    name: str,
    kind: str,
    zh: dict[str, dict[str, str]],
    en: dict[str, dict[str, str]],
    *,
    recipe: bool = False,
) -> str:
    zh_name = localized_name(name, kind, zh, recipe=recipe)
    en_name = localized_name(name, kind, en, recipe=recipe)
    label = zh_name or en_name
    if label and label != name:
        return f"{escape_md(label)}<br>`{name}`"
    return f"`{name}`"


def stack_amount(stack: Stack) -> str:
    value = stack.amount
    if stack.probability:
        value += f"（{float(stack.probability) * 100:g}% 概率）"
    if stack.temperature:
        value += f"（{stack.temperature}℃）"
    return value


def display_stacks(
    stacks: tuple[Stack, ...],
    zh: dict[str, dict[str, str]],
    en: dict[str, dict[str, str]],
) -> str:
    if not stacks:
        return "无"
    rendered = []
    for stack in stacks:
        type_note = "（流体）" if stack.kind == "fluid" else ""
        rendered.append(
            f"{display_name(stack.name, stack.kind, zh, en)} × {stack_amount(stack)}{type_note}"
        )
    return "<br>".join(rendered)


def plain_name(
    name: str,
    kind: str,
    locale: dict[str, dict[str, str]],
    *,
    recipe: bool = False,
) -> str:
    return localized_name(name, kind, locale, recipe=recipe) or ""


def plain_stacks(
    stacks: tuple[Stack, ...],
    zh: dict[str, dict[str, str]],
    en: dict[str, dict[str, str]],
) -> str:
    values = []
    for stack in stacks:
        zh_name = plain_name(stack.name, stack.kind, zh)
        en_name = plain_name(stack.name, stack.kind, en)
        names = "/".join(part for part in (zh_name, en_name, stack.name) if part)
        type_note = "[fluid]" if stack.kind == "fluid" else "[item]"
        values.append(f"{names} x {stack_amount(stack)} {type_note}")
    return "; ".join(values)


def build_markdown(
    recipes: list[Recipe],
    zh: dict[str, dict[str, str]],
    en: dict[str, dict[str, str]],
    digest: str,
) -> str:
    counts = Counter(recipe.category for recipe in recipes)
    lines = [
        "# K2配方表（本地源快照）",
        "",
        "## 文件状态",
        "",
        "本文件是写作参考资料，不是《越共》正文 canon，也不是一局实际安装模组后导出的最终配方数据库。",
        "",
        f"- 数据范围：`old/中间文件/recipe_sources/k2/` 内 {len(counts)} 个 Lua 文件，共 {len(recipes)} 条显式 `recipe` 定义。",
        "- 中文名称：优先采用本地 `k2so/Krastorio2-zh-CN.cfg`；缺失时退回 K2 英文 locale 或内部 ID。中文只用于检索和阅读，不参与数值判定。",
        "- 未包含：基础游戏配方、其他 K2 脚本在 `data-updates` 等阶段对基础配方的修改、建筑兼容列表、模组设置与实际存档中的最终覆盖结果。",
        "- 源文件中无法仅靠本快照求值的 Lua 变量名会原样保留。例如 `kr_optimization_tech_card_name` 是运行时解析的配方名，不擅自猜成某个固定物品 ID。",
        "- 本地没有 K2 `info.json` 或完整模组包，无法仅凭这批文件确认确切版本。",
        f"- 源数据 SHA-256 汇总：`{digest}`。",
        "- 本表由同目录 `生成K2配方表.py` 生成；源文件变化后可重新生成，勿手工修改表格主体。",
        "",
        "## 对小说写作的直接用法",
        "",
        "- 实验室负责研究和解锁；制造机载入已经取得的配方，检查原料并执行制造。实验室本身不直接制造手枪。",
        "- 在没有传送带、机械臂和物流网络时，可以人工把金属、零件或标准料箱送入制造机。这与游戏中手动向机器上料的逻辑相容。",
        "- 本地显式 K2 配方快照中没有 `pistol` 配方；小说采用用户确认截图中的手枪配方：制造时间5秒、铁板5、铜板5、产出1。K2归档源码中的铁板2、铜板1属于另一版本，不用于当前正文。",
        "- 全书配方时间统一按1游戏秒＝正文1小时换算。本表保留游戏原始秒数，并另列小说基准制造时长；排队、人工上料、质检、冷却、故障与停机时间另行累计。",
        "- 配方中的物料单位是游戏单位，不能直接等同于现实千克或升。小说把游戏界面的“产出1”解释为机器完成一次固定生产；原料关系不变，实际用料按这次产出的全部成品核算。",
        "- M1910式手枪在小说中每次固定生产32把，并封装为一只成品箱；32把约18.88千克，连同空箱与四层托盘仍低于24千克上限。制造完成后需由能源／解包节点开箱，才能取得和分配单把手枪。",
        "",
        "## 手枪与弹药补充参考",
        "",
        "| 对象 | K2／基础游戏原配方 | 小说适配层 |",
        "|---|---|---|",
        "| 当前采用的游戏手枪 | 制造时间5秒、铁板5、铜板5、产出1 | 自动实验室从M1910样枪得到本机可用的制造方法；5秒换算为一箱32把手枪制造5小时，实际取料按整箱质量与加工损耗缩放。枪械本身不消耗发射药 |",
        "| 基础枪械弹匣 | 制造时间1秒、铁板4、成品弹匣1；每只弹匣10发 | M1910的7.65毫米弹匣容量为7发。小说适配为制造时间1秒、铁板3、铜板1、产出1箱；正文基准制造1小时。每箱192只满装弹匣，共1344发；公司新弹采用铜包软铁芯，不含铅。第一箱在枪箱完成后另行生产，调用一次性红封舰载发射药；后续批次须等待舰载应急化工机接通 |",
        "",
        "- K2归档手枪配方（仅作版本差异参考，当前正文不采用）：<https://github.com/raiguard/Krastorio2/blob/master/prototypes/updates/base/recipes.lua#L2491-L2493>",
        "- Factorio基础枪械弹匣：<https://wiki.factorio.com/Firearm_magazine>",
        "- M1910式手枪的.32 ACP弹匣容量为7发：<https://triplek.com/browning-model-1910-32acp-7-rd-magazine-or-grips/>",
        "- Factorio 2.0.7 已删除手枪制造配方，因此这里采用的是K2对应旧版基础游戏的历史逻辑，而不是当前2.x配方。",
        "- 铁板、铜板和钢板按K2物料类型分别记账。任何配方中的钢板都保留为钢板，不向下折算成铁板；本书采用的基础弹匣配方不含钢板。",
        "- 为保持物质守恒，小说中的手枪弹必须实际消耗弹壳、铜包软铁芯弹头与发射药。公司新弹完全不使用铅；击发所需的无铅组分封装在“发射药”受控配方内部，不拆成第二张读者或普通终端可见的配方。正文008的47发旧弹只用于建立模板，试射与配发均使用枪械完成后另行生产的新弹。",
        "- 舰载应急化工机以空气、水、植物纤维和电力为输入，输出统一称为“发射药”。其催化核心会缓慢老化，现阶段既不能复制整机，也不能复制核心；接通该设备不代表本地已经完成完整化工自举。",
        "- K2／基础游戏中的 `explosives` 是炸药中间品，不是小武器发射药，基础枪械弹匣也不消耗它。本书只把该科技用于爆破采矿和爆炸武器参考，不能用它替代或倒填第007—008章的手枪弹发射药。",
        "- Factorio炸药科技：<https://wiki.factorio.com/Explosives_(research)>",
        "",
        "## 研究设施边界",
        "",
        "本地科技文件表明：`kr-laboratory` 科技解锁基础 `lab`；`kr-advanced-lab` 是更高效的实验室且不兼容基础科技卡；`kr-research-server` 用于生产复杂科技卡；`kr-singularity-lab` 属于更后期的研究设施。它们支持“研究—解锁—制造”的分工，不支持“实验室直接吐出成品”。",
        "",
        "## 分类统计",
        "",
        "| 内部类别 | 中文说明 | 配方数 |",
        "|---|---:|---:|",
    ]
    for category in sorted(counts):
        lines.append(f"| `{category}` | {CATEGORY_ZH.get(category, '未译')} | {counts[category]} |")
    lines.extend(["", "## 配方明细", ""])
    for category in sorted(counts):
        category_recipes = sorted(
            (recipe for recipe in recipes if recipe.category == category),
            key=lambda recipe: recipe.recipe_id,
        )
        lines.extend(
            [
                f"### {CATEGORY_ZH.get(category, category)}（`{category}`，{len(category_recipes)}条）",
                "",
                "| 配方 | 游戏耗时（秒） | 小说基准制造时长 | 初始可用 | 输入 | 输出 | 源位置 |",
                "|---|---:|---:|:---:|---|---|---|",
            ]
        )
        for recipe in category_recipes:
            source = (
                f"[{recipe.source_file}:{recipe.source_line}]"
                f"(../../old/中间文件/recipe_sources/k2/{recipe.source_file}#L{recipe.source_line})"
            )
            lines.append(
                "| "
                + " | ".join(
                    [
                        display_name(recipe.recipe_id, "item", zh, en, recipe=True),
                        recipe.energy_required,
                        story_duration_hours(recipe.energy_required),
                        "是" if recipe.enabled else "否",
                        display_stacks(recipe.ingredients, zh, en),
                        display_stacks(recipe.results, zh, en),
                        source,
                    ]
                )
                + " |"
            )
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def build_csv(
    recipes: list[Recipe],
    zh: dict[str, dict[str, str]],
    en: dict[str, dict[str, str]],
) -> bytes:
    output = io.StringIO(newline="")
    writer = csv.writer(output)
    writer.writerow(
        [
            "source_file",
            "source_line",
            "category",
            "category_zh",
            "recipe_id",
            "recipe_name_zh",
            "recipe_name_en",
            "energy_required",
            "story_duration_hours",
            "enabled",
            "ingredients",
            "results",
        ]
    )
    for recipe in sorted(recipes, key=lambda item: (item.category, item.recipe_id)):
        writer.writerow(
            [
                recipe.source_file,
                recipe.source_line,
                recipe.category,
                CATEGORY_ZH.get(recipe.category, ""),
                recipe.recipe_id,
                plain_name(recipe.recipe_id, "item", zh, recipe=True),
                plain_name(recipe.recipe_id, "item", en, recipe=True),
                recipe.energy_required,
                story_duration_value(recipe.energy_required),
                "true" if recipe.enabled else "false",
                plain_stacks(recipe.ingredients, zh, en),
                plain_stacks(recipe.results, zh, en),
            ]
        )
    return output.getvalue().encode("utf-8-sig")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--check", action="store_true", help="只校验生成结果是否为最新")
    args = parser.parse_args()

    source_paths = sorted(SOURCE_DIR.glob("*.lua"), key=lambda path: path.name)
    if not source_paths:
        raise SystemExit(f"未找到 K2 配方源：{SOURCE_DIR}")

    recipes = [recipe for path in source_paths for recipe in parse_recipe_file(path)]
    if len(recipes) != 119:
        raise SystemExit(f"配方数量异常：预期 119，实际 {len(recipes)}")
    ids = [recipe.recipe_id for recipe in recipes]
    duplicates = sorted(name for name, count in Counter(ids).items() if count > 1)
    if duplicates:
        raise SystemExit(f"发现重复配方 ID：{', '.join(duplicates)}")

    zh = parse_locale(ZH_LOCALE)
    en = parse_locale(EN_LOCALE)
    markdown = build_markdown(recipes, zh, en, source_digest(source_paths)).encode("utf-8")
    csv_bytes = build_csv(recipes, zh, en)

    if args.check:
        problems = []
        if not MD_PATH.exists() or MD_PATH.read_bytes() != markdown:
            problems.append(MD_PATH.name)
        if not CSV_PATH.exists() or CSV_PATH.read_bytes() != csv_bytes:
            problems.append(CSV_PATH.name)
        if problems:
            raise SystemExit("生成文件不是最新版本：" + "、".join(problems))
        print(f"校验通过：{len(source_paths)} 个源文件，{len(recipes)} 条配方。")
        return 0

    MD_PATH.write_bytes(markdown)
    CSV_PATH.write_bytes(csv_bytes)
    print(f"已生成 {MD_PATH.name} 与 {CSV_PATH.name}：{len(recipes)} 条配方。")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
