"""遍历 _001__clawler/方剂/ 下的方剂详情 txt，调用 LLM 抽取实体和关系，一方一个 json。

和 01_extract_herb_graph.py 是一对镜像脚本，逻辑一致，只换输入输出目录 —— 改一边
的行为时记得看另一边要不要同步。prompt 不用换：__000__ 里那套已经带了方剂的
FormulaAttributes 和 HAS_INGREDIENT / FROM_SOURCE，实测方剂文本能正确抽出组成药材
和出处。

支持断点续抽：已经存在对应 json 的方剂直接跳过，中断后重跑即可继续。

输出：
    _002__extract_information/方剂图谱/<方剂名>.json   成功（合法的空结果也算成功）
    _002__extract_information/方剂图谱/_failed.log     失败清单，每行「方剂名<TAB>原因」
    _002__extract_information/alpaca_formula_dataset.jsonl  顺便追加的 Alpaca 微调样本

数据集是抽取时顺手追加的，只覆盖「本次抽的」那些方剂。已经抽过的会被断点续抽跳过，
不会再写进数据集 —— 要补齐全量或去重，跑 04_build_formula_alpaca_dataset.py 从
方剂图谱/*.json 重建。

方剂文本比中药短得多（均值 377 字 vs 中药 1537），单条实测 1~2 分钟，
703 条 10 线程大约 2~3 小时。

用法（在项目根目录执行）：
    python -X utf8 -m _002__extract_information.03_extract_formula_graph --limit 5   # 先抽 5 条看效果
    python -X utf8 -m _002__extract_information.03_extract_formula_graph             # 全量，10 线程
    python -X utf8 -m _002__extract_information.03_extract_formula_graph --workers 20
    python -X utf8 -m _002__extract_information.03_extract_formula_graph --retry-failed
"""

import argparse
import json
import os
import threading
import time

from concurrent.futures import ThreadPoolExecutor, as_completed

from tqdm import tqdm

from common.path_utils import get_file_path
from _002__extract_information.__000__extract_graph_data_utils import (
    build_alpaca_record,
    extract_tcm_knowledge,
)

INPUT_DIR = get_file_path("_001__clawler/方剂")
OUTPUT_DIR = get_file_path("_002__extract_information/方剂图谱")
FAILED_LOG = os.path.join(OUTPUT_DIR, "_failed.log")
# 微调数据集用 jsonl 追加：一行一条，多线程各写一行不会互相截断，
# 中途崩了也只会丢最后一行，不会把前面写好的记录带坏。
ALPACA_JSONL = get_file_path("_002__extract_information/alpaca_formula_dataset.jsonl")

# 失败清单是多线程追加写的，加锁避免两行日志挤在一起
_failed_lock = threading.Lock()
# 数据集同理，一次只让一个线程追加
_alpaca_lock = threading.Lock()


def read_failed_names():
    """读失败清单里的方剂名，用于 --retry-failed。"""
    if not os.path.exists(FAILED_LOG):
        return set()
    names = set()
    with open(FAILED_LOG, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                names.add(line.split("\t")[0])
    return names


def record_failure(name, reason):
    """把失败原因追加到清单。写成一行，方便之后 --retry-failed 挑出来重跑。"""
    reason = " ".join(str(reason).split())[:200]
    with _failed_lock:
        with open(FAILED_LOG, "a", encoding="utf-8") as f:
            f.write(f"{name}\t{reason}\n")


def append_alpaca(text, entities, relations):
    """把这一条抽取结果追加成一行 Alpaca 样本。

    只追加、不去重 —— 断点续抽保证同一个方剂一轮里只会抽一次。真要补历史或
    去重，跑 04_build_formula_alpaca_dataset.py 从图谱 json 重建。
    """
    record = build_alpaca_record(text, entities, relations)
    line = json.dumps(record, ensure_ascii=False)
    with _alpaca_lock:
        with open(ALPACA_JSONL, "a", encoding="utf-8") as f:
            f.write(line + "\n")


def extract_one(name, retries=2):
    """抽一个方剂并落盘。返回 (name, ok, note, 实体数, 关系数)。由线程池并发调用。

    LLM 偶发超时/限流/返回非法 json，失败退避重试；仍然失败就记进清单，
    不写 json —— 这样下次跑还会被当成待抽项，断点续抽才是对的。
    """
    input_path = os.path.join(INPUT_DIR, f"{name}.txt")
    output_path = os.path.join(OUTPUT_DIR, f"{name}.json")

    try:
        with open(input_path, encoding="utf-8") as f:
            text = f.read()
    except OSError as exc:
        record_failure(name, f"读取失败: {exc}")
        return name, False, "读取失败", 0, 0

    if not text.strip():
        record_failure(name, "源文件为空")
        return name, False, "源文件为空", 0, 0

    last_exc = None
    for attempt in range(1, retries + 1):
        try:
            result = extract_tcm_knowledge(text)
            break
        except Exception as exc:  # 网络、限流、json 解析失败都在这
            last_exc = exc
            if attempt < retries:
                time.sleep(5 * attempt)
    else:
        record_failure(name, f"抽取失败: {last_exc}")
        return name, False, "抽取失败", 0, 0

    # parser 是 JsonOutputParser，正常返回 dict；模型偶尔会吐出 list 或别的形状
    if not isinstance(result, dict):
        record_failure(name, f"返回结构异常: {type(result).__name__}")
        return name, False, "结构异常", 0, 0

    entities = result.get("entities") or []
    relations = result.get("relations") or []
    # 空结果也落盘，否则每次重跑都会重复调用这些方剂
    payload = {
        "name": name,
        "source_file": os.path.relpath(input_path, get_file_path("")),
        "entities": entities,
        "relations": relations,
    }

    # 先写临时文件再改名，中断时不会留下半个 json 被误判成已完成
    temp_path = f"{output_path}.{threading.get_ident()}.part"
    with open(temp_path, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
    os.replace(temp_path, output_path)

    # 图谱 json 落盘之后再追加数据集：反过来的话，万一追加完崩了、json 还没写成，
    # 续抽会把这个方剂重抽一遍，数据集里就多出一条重复样本。
    append_alpaca(text, entities, relations)

    note = f"{len(entities)}实体/{len(relations)}关系"
    return name, True, note, len(entities), len(relations)


def build_todo(limit=0, retry_failed=False):
    """列出本次要抽的方剂名。断点续抽的核心：已有 json 的直接排除。"""
    names = sorted(
        os.path.splitext(f)[0]
        for f in os.listdir(INPUT_DIR)
        if f.endswith(".txt")
    )
    done = {
        os.path.splitext(f)[0]
        for f in os.listdir(OUTPUT_DIR)
        if f.endswith(".json")
    }
    todo = [n for n in names if n not in done]

    if retry_failed:
        # 失败的本来就没写 json，已经在 todo 里；这里是缩小范围只跑它们
        failed = read_failed_names()
        todo = [n for n in todo if n in failed]

    if limit:
        todo = todo[:limit]
    return names, done, todo


def run(limit=0, workers=10, retry_failed=False):
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    names, done, todo = build_todo(limit=limit, retry_failed=retry_failed)

    print(f"源目录共 {len(names)} 个方剂，已抽取 {len(done)} 个，本次待抽 {len(todo)} 个")
    print(f"并发线程数: {workers}")
    if not todo:
        print("没有需要抽取的内容。")
        return

    ok_count = fail_count = 0
    entity_total = relation_total = 0

    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(extract_one, name): name for name in todo}
        # tqdm 显示进度；每条结果用 write() 打，避免把进度条撕开
        with tqdm(total=len(todo), desc="抽取中", unit="方", ncols=90) as bar:
            for future in as_completed(futures):
                name = futures[future]
                try:
                    _, ok, note, n_entity, n_relation = future.result()
                except Exception as exc:  # 单条异常不该拖垮整轮
                    record_failure(name, f"未捕获异常: {exc}")
                    ok, note, n_entity, n_relation = False, "异常", 0, 0
                if ok:
                    ok_count += 1
                    entity_total += n_entity
                    relation_total += n_relation
                else:
                    fail_count += 1
                bar.write(f"  {'✓' if ok else '✗'} {name} {note}")
                bar.update(1)
                bar.set_postfix(成功=ok_count, 失败=fail_count)

    print(f"\n完成 {ok_count} 个，失败 {fail_count} 个")
    print(f"共抽出 {entity_total} 个实体、{relation_total} 条关系")
    print(f"输出目录: {OUTPUT_DIR}")
    print(f"微调数据集（追加 {ok_count} 条）: {ALPACA_JSONL}")
    if fail_count:
        print(f"失败清单: {FAILED_LOG}（可用 --retry-failed 只重跑这些）")


def main():
    parser = argparse.ArgumentParser(description="抽取方剂详情中的实体和关系")
    parser.add_argument(
        "--limit",
        type=int,
        default=0,
        help="本次最多抽多少个，0 表示不限制（默认 0）",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=40,
        help="并发线程数（默认 10，设 1 就是单线程）",
    )
    parser.add_argument(
        "--retry-failed",
        action="store_true",
        help="只重跑 _failed.log 里记过的方剂",
    )
    args = parser.parse_args()
    if args.workers < 1:
        parser.error("--workers 至少为 1")
    run(limit=args.limit, workers=args.workers, retry_failed=args.retry_failed)


if __name__ == "__main__":
    main()
