"""Info Extractor - 基于 LangChain 的信息提取管线.

提供两种提取模式:
1. single_phase (默认): 单次调用完成提取+结构化，高效快速
2. two_phase: 先分析提取事实，再结构化为 JSON，适合复杂文章

以及可选的标注功能:
3. annotate: 在原文中标注关键信息的提取位置

核心流程:
    Article + Definition
        → [SchemaFlattener] 预处理定义
        → [PromptBuilder]  构建 Prompt
        → [LLM]           调用大模型
        → [Parser]        解析 JSON
        → [Validator]     校验结果
        → [Retry]         失败重试（含修正提示）
        → Validated Instance
        → (可选) [Annotator] LLM 标注原文 → 标注后文本

依赖: langchain-openai (兼容 OpenAI API 格式的所有模型)
"""

from __future__ import annotations

import json
import logging
import re
import sys
from typing import Any, Iterator

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI

from src.prompts import (
    build_analysis_prompt,
    build_annotation_prompt,
    build_definition_prompt,
    build_definition_retry_prompt,
    build_retry_prompt,
    build_single_phase_prompt,
    build_structure_prompt,
)
from src.schema_flattener import generate_output_template
from src.validator import validate, validate_definition

logger = logging.getLogger(__name__)

DefinitionNode = dict[str, Any]

# JSON 提取正则: 匹配 ```json ... ``` 代码块
_JSON_BLOCK_PATTERN = re.compile(r"```json\s*\n?(.*?)\n?\s*```", re.DOTALL)
# 兜底: 尝试匹配裸 JSON 对象（最外层 { }）
_RAW_JSON_PATTERN = re.compile(r"\{[\s\S]*\}", re.DOTALL)


class ExtractionError(Exception):
    """信息提取过程中的错误."""


class InfoExtractor:
    """结构化信息提取器.

    Args:
        model_name: 模型名称 (如 "gpt-4o", "deepseek-chat")
        temperature: 生成温度，建议 0 以获得稳定输出
        api_key: API 密钥
        base_url: API 基础 URL (兼容 OpenAI API 格式的第三方服务)
        max_retries: 最大重试次数 (含首次调用)
    """

    def __init__(
        self,
        model_name: str = "gpt-4o",
        temperature: float = 0,
        api_key: str | None = None,
        base_url: str | None = None,
        max_retries: int = 3,
    ) -> None:
        kwargs: dict[str, Any] = {
            "model": model_name,
            "temperature": temperature,
        }
        if api_key:
            kwargs["api_key"] = api_key
        if base_url:
            kwargs["base_url"] = base_url

        self.llm: BaseChatModel = ChatOpenAI(**kwargs)
        self.max_retries = max_retries

    # ── 公共接口 ──────────────────────────────────────────────────────────────

    def extract(
        self,
        article: str,
        definition: DefinitionNode,
        mode: str = "single_phase",
        verbose: bool = False,
        annotate: bool = False,
        debug: bool = False,
    ) -> dict[str, Any]:
        """从文章中提取关键信息.

        Args:
            article: 文章文本
            definition: 信息定义 JSON
            mode: 提取模式 ("single_phase" | "two_phase")
            verbose: 为 True 时流式输出模型思考过程到 stdout
            annotate: 为 True 时同时生成标注后的文章文本
            debug: 为 True 时打印每次 LLM 交互的步骤标签和完整 prompt

        Returns:
            当 annotate=False 时，返回提取得到的实例 JSON (dict)。
            当 annotate=True 时，返回:
            {
                "instance": {...},         # 提取结果
                "annotated_text": "..."    # 标注后的文章文本
            }

        Raises:
            ValueError: 输入参数不合法
            ExtractionError: 提取失败（所有重试均失败）
        """
        self._validate_inputs(article, definition)

        if mode == "single_phase":
            instance = self._extract_single_phase(article, definition, verbose=verbose, debug=debug)
        elif mode == "two_phase":
            instance = self._extract_two_phase(article, definition, verbose=verbose, debug=debug)
        else:
            raise ValueError(f"不支持的提取模式: {mode}")

        if annotate:
            annotated = self.annotate(article, definition, instance, verbose=verbose, debug=debug)
            return {"instance": instance, "annotated_text": annotated}

        return instance

    def extract_definition(
        self,
        text: str,
        verbose: bool = False,
        debug: bool = False,
    ) -> DefinitionNode:
        """从文本中自动生成关键信息定义 JSON.

        分析输入文本，利用大模型生成符合规范的信息定义树结构，
        并对生成的定义进行结构校验。校验失败时自动将错误信息反馈给
        大模型进行重试。

        Args:
            text: 输入文本
            verbose: 为 True 时流式输出模型思考过程到 stdout
            debug: 为 True 时打印每次 LLM 交互的步骤标签和完整 prompt

        Returns:
            生成的信息定义 JSON（dict）

        Raises:
            ValueError: 输入文本为空
            ExtractionError: 所有重试均失败
        """
        if not text or not text.strip():
            raise ValueError("输入文本不能为空")

        messages = build_definition_prompt(text)
        return self._extract_definition_with_retry(
            messages, verbose=verbose, debug=debug,
        )

    def annotate(
        self,
        article: str,
        definition: DefinitionNode,
        instance: dict[str, Any],
        verbose: bool = False,
        debug: bool = False,
    ) -> str:
        """在原文中标注关键信息的提取位置.

        对已提取的实例 JSON，生成标注后的文章文本，每个关键信息区域
        用 {{path||value||raw_text||constraint||transform}} 标记。

        流程:
        1. 扁平化 instance → 叶子属性列表
        2a. [LLM 辅助] 为每个提取值定位原文中的精确文本片段
        2b. [LLM 辅助] 基于精确原文片段生成值约束和转换规则
        3. [LLM 标注] 在原文中标注每个字段的提取位置

        Args:
            article: 原始文章文本
            definition: 信息定义 JSON
            instance: 已提取的实例 JSON
            verbose: 为 True 时流式输出模型思考过程
            debug: 为 True 时打印步骤标签和发送给 LLM 的 prompt

        Returns:
            标注后的文章文本

        Raises:
            ExtractionError: LLM 标注调用失败
        """
        from src.annotator import (
            build_constraint_transform_prompt,
            build_raw_text_prompt,
            flatten_instance_for_annotation,
            format_annotation_items_text,
            parse_constraint_transform_response,
            parse_raw_text_response,
        )

        # 空结果保护：无需标注时直接返回原文
        if not instance:
            logger.info("实例为空，跳过标注")
            return article

        # Step 1: 扁平化
        items = flatten_instance_for_annotation(definition, instance)

        # 过滤掉值为 None 的项（缺失信息无法在原文中定位）
        non_null_items = [it for it in items if it["value"] is not None]
        if not non_null_items:
            logger.info("所有字段值为 null，跳过标注")
            return article

        # 长文本性能警告
        _TEXT_LENGTH_WARNING = 15000
        if len(article) > _TEXT_LENGTH_WARNING:
            logger.warning(
                f"文本长度 ({len(article)}) 超过建议上限 ({_TEXT_LENGTH_WARNING})，"
                "标注质量可能下降"
            )

        # Step 2a: LLM 定位每个提取值在原文中的精确片段 (轻量级调用)
        logger.info("定位提取值对应的原文片段...")
        rt_messages = build_raw_text_prompt(article, non_null_items)
        rt_response = self._invoke_llm(
            rt_messages, verbose=False, debug=debug,
            step_label="标注 - Step 2a: 定位原文片段",
        )
        non_null_items = parse_raw_text_response(rt_response, non_null_items)

        # Step 2b: LLM 基于精确原文片段生成约束和转换规则 (轻量级调用)
        logger.info("生成约束和转换规则...")
        ct_messages = build_constraint_transform_prompt(non_null_items)
        ct_response = self._invoke_llm(
            ct_messages, verbose=False, debug=debug,
            step_label="标注 - Step 2b: 生成约束和转换规则",
        )
        non_null_items = parse_constraint_transform_response(ct_response, non_null_items)

        # Step 3: LLM 在原文中标注
        items_text = format_annotation_items_text(non_null_items)
        messages = build_annotation_prompt(article, items_text)

        logger.info("生成文章标注...")
        raw_output = self._invoke_llm(
            messages, verbose=verbose, debug=debug,
            step_label="标注 - Step 3: 在原文中标注",
        )

        # 清理 LLM 可能附加的包裹标记
        annotated = _clean_annotation_output(raw_output, article)

        # 用原文校验并自动修复标注（不依赖 LLM）
        # 校验规则：{{}} 外的文本 + {{}} 内 raw_text 拼接，应等于原文
        annotated = self._validate_and_repair_annotation(article, annotated)

        logger.info("文章标注完成")
        return annotated

    def _validate_and_repair_annotation(
        self,
        article: str,
        annotated: str,
    ) -> str:
        """校验标注文本能否还原原文，不能则尝试自动修复.

        纯算法实现，不再调用 LLM。
        通过 step/thinking 通道把校验与修复过程实时推送到前端思考面板
        （与 LLM 阶段相同的缩进格式，便于在思考面板中查看）。
        """
        from src.annotation_repair import repair_annotation, validate_annotation

        # 通知前端进入新阶段
        self._emit_step("标注 - Step 4: 标注校验与修复（不依赖 LLM）")

        # 为本阶段输出建立缩进器（与其他 LLM 阶段保持一致的 4 空格缩进）
        indenter = _Indenter(indent="    ", emit=self._emit_raw)
        prev_emitter = getattr(self, "_thinking_emitter", None)
        self._thinking_emitter = indenter.write

        try:
            is_valid, reconstructed = validate_annotation(article, annotated)
            if is_valid:
                self._emit_thinking(
                    "校验通过：{{}} 外文本 + 各 {{}} 内 raw_text 拼接等于原文，无需修复。\n"
                )
                logger.info("标注与原文一致，跳过修复")
                return annotated

            self._emit_thinking(
                f"校验未通过：{{}} 外文本 + raw_text 拼接结果与原文不等。\n"
                f"  重建长度: {len(reconstructed)}, 原文长度: {len(article)}\n"
                "策略：用 difflib.SequenceMatcher 把每个 segment 边界对齐到原文位置，"
                "用原文对应片段替换不一致的 segment（{{}} 外文本 + {{}} 内 raw_text 一并处理），"
                "保持最小编辑距离。\n"
            )
            logger.info("标注与原文不一致，尝试自动修复...")

            repaired, logs = repair_annotation(article, annotated)
            for log in logs:
                self._emit_thinking(f"  [修复] {log}\n")
                logger.info(f"  [修复] {log}")

            is_valid_after, _ = validate_annotation(article, repaired)
            if is_valid_after:
                self._emit_thinking(
                    "二次校验通过，采用修复后的标注文本。\n"
                    f"  修复后: {repaired}\n"
                )
                logger.info(f"修复后标注: {repaired}")
                return repaired

            self._emit_thinking(
                "二次校验仍未通过，保留原始标注文本。\n"
                f"  原标注: {annotated}\n"
            )
            return annotated
        finally:
            indenter.flush()
            self._thinking_emitter = prev_emitter

    @staticmethod
    def _validate_inputs(article: str, definition: DefinitionNode) -> None:
        """校验输入参数的合法性."""
        if not article or not article.strip():
            raise ValueError("文章内容不能为空")
        if not isinstance(definition, dict):
            raise TypeError(f"definition 必须为 dict，实际为 {type(definition).__name__}")
        if "metadata" not in definition:
            raise ValueError("definition 缺少 metadata 字段")

    # ── 单阶段提取 ────────────────────────────────────────────────────────────

    def _extract_single_phase(
        self,
        article: str,
        definition: DefinitionNode,
        verbose: bool = False,
        debug: bool = False,
    ) -> dict[str, Any]:
        messages = build_single_phase_prompt(article, definition)
        return self._extract_with_retry(
            messages, definition, verbose=verbose, debug=debug,
            step_label="单阶段提取",
        )

    # ── 两阶段提取 ────────────────────────────────────────────────────────────

    def _extract_two_phase(
        self,
        article: str,
        definition: DefinitionNode,
        verbose: bool = False,
        debug: bool = False,
    ) -> dict[str, Any]:
        # Phase 1: 信息分析
        logger.info("[Phase 1] 分析文章，提取原始信息...")
        analysis_messages = build_analysis_prompt(article, definition)
        analysis_result = self._invoke_llm(
            analysis_messages, verbose=verbose, debug=debug,
            step_label="两阶段提取 - Phase 1: 信息分析",
        )

        logger.info("[Phase 2] 结构化为 JSON...")
        structure_messages = build_structure_prompt(analysis_result, definition)
        return self._extract_with_retry(
            structure_messages, definition, verbose=verbose, debug=debug,
            step_label="两阶段提取 - Phase 2: 结构化",
        )

    # ── 重试机制 ──────────────────────────────────────────────────────────────

    def _extract_with_retry(
        self,
        initial_messages: list[dict[str, str]],
        definition: DefinitionNode,
        verbose: bool = False,
        debug: bool = False,
        step_label: str = "提取",
    ) -> dict[str, Any]:
        messages = list(initial_messages)
        last_instance: dict[str, Any] | None = None

        for attempt in range(1, self.max_retries + 1):
            logger.info(f"提取尝试 {attempt}/{self.max_retries}")

            # 调用 LLM
            attempt_label = f"{step_label} (attempt {attempt}/{self.max_retries})"
            raw_output = self._invoke_llm(
                messages, verbose=verbose, debug=debug, step_label=attempt_label,
            )
            logger.debug(f"LLM 原始输出:\n{raw_output}")

            # 解析 JSON
            instance = self._parse_json(raw_output)
            if instance is None:
                logger.warning(f"JSON 解析失败 (尝试 {attempt})")
                messages.append({"role": "assistant", "content": raw_output})
                messages.append({
                    "role": "user",
                    "content": "上一次输出无法解析为合法 JSON，请重新输出。"
                    "确保 JSON 被包裹在 ```json ``` 代码块中，且格式正确。",
                })
                continue

            last_instance = instance

            # 校验
            errors = validate(instance, definition)
            if not errors:
                logger.info("校验通过，提取成功")
                return instance

            logger.warning(f"校验失败 (尝试 {attempt}): {errors}")
            if attempt < self.max_retries:
                messages.append({"role": "assistant", "content": raw_output})
                messages.append({"role": "user", "content": build_retry_prompt(errors)})

        # 所有重试均失败
        if last_instance is not None:
            logger.warning("所有重试均未通过校验，返回最后一次结果（可能包含错误）")
            return last_instance

        raise ExtractionError(
            f"经过 {self.max_retries} 次尝试仍无法提取有效信息"
        )

    def _extract_definition_with_retry(
        self,
        initial_messages: list[dict[str, str]],
        verbose: bool = False,
        debug: bool = False,
    ) -> DefinitionNode:
        """带重试机制的定义生成：解析 JSON 并用 validate_definition 校验.

        校验失败时将错误信息追加到对话中，要求 LLM 修正后重新输出。
        若所有重试均未通过校验，返回最后一次解析到的结果（可能不完整）。
        """
        messages = list(initial_messages)
        last_definition: DefinitionNode | None = None

        for attempt in range(1, self.max_retries + 1):
            logger.info(f"定义生成尝试 {attempt}/{self.max_retries}")

            attempt_label = f"定义生成 (attempt {attempt}/{self.max_retries})"
            raw_output = self._invoke_llm(
                messages, verbose=verbose, debug=debug, step_label=attempt_label,
            )
            logger.debug(f"LLM 原始输出:\n{raw_output}")

            definition = self._parse_json(raw_output)
            if definition is None:
                logger.warning(f"JSON 解析失败 (尝试 {attempt})")
                messages.append({"role": "assistant", "content": raw_output})
                messages.append({
                    "role": "user",
                    "content": "上一次输出无法解析为合法 JSON，请重新输出。"
                    "确保 JSON 被包裹在 ```json ``` 代码块中，且格式正确。",
                })
                continue

            last_definition = definition

            errors = validate_definition(definition)
            if not errors:
                logger.info("定义结构校验通过")
                return definition

            logger.warning(f"定义校验失败 (尝试 {attempt}): {errors}")
            if attempt < self.max_retries:
                messages.append({"role": "assistant", "content": raw_output})
                messages.append({"role": "user", "content": build_definition_retry_prompt(errors)})

        if last_definition is not None:
            logger.warning("所有重试均未通过校验，返回最后一次结果（可能包含错误）")
            return last_definition

        raise ExtractionError(
            f"经过 {self.max_retries} 次尝试仍无法生成有效的信息定义"
        )

    # ── LLM 调用 ──────────────────────────────────────────────────────────────

    def _invoke_llm(
        self,
        messages: list[dict[str, str]],
        verbose: bool = False,
        debug: bool = False,
        step_label: str = "",
    ) -> str:
        """调用 LLM，捕获网络/API 级别异常并转为 ExtractionError.

        Args:
            messages: 消息列表
            verbose: 为 True 时使用流式输出
            debug: 为 True 时打印步骤标签和发送给 LLM 的 prompt
            step_label: 当前步骤的描述标签（用于 debug 日志）
        """
        if debug:
            _log_debug_prompt(step_label, messages)

        # 通知前端进入新阶段（step 名称 + 分隔）
        self._emit_step(step_label)

        # 为本阶段思考输出建立带 4 空格缩进的流式回调
        # 注意：indenter.emit 必须直接指向真正发送的底层函数 _emit_raw，
        # 不能指向 _emit_thinking，否则会形成 indenter.write -> _emit_thinking
        # -> stream_callback(=indenter.write) -> ... 的递归。
        indenter = _Indenter(
            indent="    ",
            emit=self._emit_raw,
        )
        prev_emitter = getattr(self, "_thinking_emitter", None)
        self._thinking_emitter = indenter.write

        lc_messages = self._to_lc_messages(messages)

        try:
            if verbose:
                return self._invoke_llm_streamed(lc_messages)
            response = self.llm.invoke(lc_messages)
        except Exception as exc:
            raise ExtractionError(f"LLM 调用失败: {exc}") from exc
        finally:
            indenter.flush()
            self._thinking_emitter = prev_emitter
        return response.content

    def _invoke_llm_streamed(self, lc_messages: list[BaseMessage]) -> str:
        """流式调用 LLM，逐 chunk 输出到 stdout 并通过回调透传给前端.

        直接使用底层 OpenAI 客户端流式调用，绕过 LangChain 的 stream() 封装，
        以获取原始 delta 中的 reasoning_content（LangChain 不透传此字段）。

        常规内容和推理内容都会通过 _emit_thinking 回调实时发送给前端。
        返回值仅包含 delta.content（模型的正式输出）。

        兼容:
        - GLM-5.x: reasoning_content 在 delta.model_extra 中
        - DeepSeek-R1: reasoning_content 在 delta 中 (视 SDK 版本)
        - 通用模型: content 在 delta.content 中
        """
        openai_messages = self._lc_to_openai_messages(lc_messages)

        content_chunks: list[str] = []
        raw_client = self.llm.client

        stream = raw_client.create(
            model=self.llm.model_name,
            messages=openai_messages,
            stream=True,
        )

        for event in stream:
            if not event.choices:
                continue
            delta = event.choices[0].delta

            # 推理/思考过程 —— 实时透传给前端
            # 1) 直接属性 (部分 SDK 版本)
            reasoning = getattr(delta, "reasoning_content", None)
            # 2) Pydantic model_extra (OpenAI SDK v1.x, GLM-5.x 走这条路径)
            if not reasoning:
                reasoning = getattr(delta, "model_extra", {}).get(
                    "reasoning_content"
                )
            if reasoning:
                sys.stdout.write(reasoning)
                sys.stdout.flush()
                self._emit_thinking(reasoning)

            # 常规内容 —— 显示 + 透传 + 拼入返回值
            text = delta.content
            if text:
                sys.stdout.write(text)
                sys.stdout.flush()
                self._emit_thinking(text)
                content_chunks.append(text)

        sys.stdout.write("\n")
        sys.stdout.flush()
        return "".join(content_chunks)

    def _emit_thinking(self, content: str) -> None:
        """将流式思考内容透传出去（可选经过当前阶段的缩进器处理）.

        调用关系：``_invoke_llm_streamed`` → ``_emit_thinking`` →
        当前阶段绑定的 ``_thinking_emitter``（通常是 ``_Indenter.write``）
        → ``_emit_raw`` → ``stream_callback``（由后端注入，例如入队列）。
        """
        emitter = getattr(self, "_thinking_emitter", None)
        if emitter:
            emitter(content)
        else:
            self._emit_raw(content)

    def _emit_raw(self, content: str) -> None:
        """底层发送：将内容直接送往后端 SSE 队列."""
        callback = getattr(self, "stream_callback", None)
        if callback:
            callback(content)

    def _emit_step(self, step_label: str) -> None:
        """通知前端进入新的处理阶段.

        若设置了 ``step_callback``，则回调接收 step 名称；
        同时也会通过 ``_emit_thinking`` 推送一行阶段标题，
        以保证仅订阅 thinking 通道时也能看到阶段切换。
        """
        callback = getattr(self, "step_callback", None)
        if callback:
            callback(step_label or "")

    @staticmethod
    def _to_lc_messages(messages: list[dict[str, str]]) -> list[BaseMessage]:
        """将 dict 格式的消息列表转为 LangChain Message 对象."""
        lc_messages: list[BaseMessage] = []
        for msg in messages:
            role = msg["role"]
            if role == "system":
                lc_messages.append(SystemMessage(content=msg["content"]))
            elif role == "assistant":
                lc_messages.append(AIMessage(content=msg["content"]))
            else:
                lc_messages.append(HumanMessage(content=msg["content"]))
        return lc_messages

    @staticmethod
    def _lc_to_openai_messages(lc_messages: list[BaseMessage]) -> list[dict[str, str]]:
        """将 LangChain Message 对象转为 OpenAI API dict 格式."""
        result: list[dict[str, str]] = []
        for msg in lc_messages:
            if isinstance(msg, SystemMessage):
                result.append({"role": "system", "content": msg.content})
            elif isinstance(msg, AIMessage):
                result.append({"role": "assistant", "content": msg.content})
            else:
                result.append({"role": "user", "content": msg.content})
        return result

    # ── JSON 解析 ─────────────────────────────────────────────────────────────

    @staticmethod
    def _parse_json(raw: str) -> dict[str, Any] | None:
        """从 LLM 输出中提取 JSON.

        优先匹配 ```json ``` 代码块，兜底匹配裸 JSON。
        """
        # 尝试匹配代码块
        match = _JSON_BLOCK_PATTERN.search(raw)
        if match:
            return _safe_json_loads(match.group(1))

        # 兜底: 匹配裸 JSON
        match = _RAW_JSON_PATTERN.search(raw)
        if match:
            return _safe_json_loads(match.group(0))

        return None


def _safe_json_loads(s: str) -> dict[str, Any] | None:
    try:
        result = json.loads(s.strip())
        if isinstance(result, dict):
            return result
        return None
    except (json.JSONDecodeError, TypeError):
        return None


def _log_debug_prompt(step_label: str, messages: list[dict[str, str]]) -> None:
    """打印 debug 日志：步骤标签 + 发送给 LLM 的完整 prompt."""
    separator = "═" * 60
    logger.info(separator)
    logger.info(f"  LLM 调用步骤: {step_label}")
    logger.info(separator)
    for msg in messages:
        role = msg["role"]
        content = msg["content"]
        role_tag = {"system": "SYSTEM", "user": "USER", "assistant": "ASSISTANT"}.get(role, role.upper())
        logger.info(f"── [{role_tag}] ──")
        logger.info(content)
    logger.info(separator)


def _clean_annotation_output(raw: str, original_article: str) -> str:
    """清理 LLM 标注输出中可能附加的包裹标记.

    LLM 可能在标注文本前后添加不必要的格式化内容:
    - ```text ... ``` 代码块包裹
    - 前后空白行
    - 开头/结尾的引号
    """
    text = raw.strip()

    # 移除代码块包裹
    if text.startswith("```") and text.endswith("```"):
        # 匹配 ```text 或 ``` 包裹
        lines = text.split("\n")
        if lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

    return text


class _Indenter:
    """流式文本缩进器.

    跨 chunk 维护"是否位于行首"的状态，在每个换行后的第一个字符前
    自动插入指定缩进，从而把任意流式输出的每一行都前置 N 个空格。

    例如将逐 chunk 到达的 "abc\\ndef" + "ghi\\n" 缩进 4 空格后变为
    "    abc\\n    defghi\\n"。
    """

    def __init__(self, indent: str, emit) -> None:
        self.indent = indent
        self.emit = emit
        self._at_line_start = True

    def write(self, text: str) -> None:
        if not text:
            return
        out: list[str] = []
        for ch in text:
            if self._at_line_start and ch != "\n":
                out.append(self.indent)
            out.append(ch)
            self._at_line_start = ch == "\n"
        self.emit("".join(out))

    def flush(self) -> None:
        """阶段结束时调用，确保末尾换行以便后续阶段从新行开始."""
        if not self._at_line_start:
            self.emit("\n")
            self._at_line_start = True
