# -*- coding: utf-8 -*-
"""
回归测试：多轮工具调用中，上一轮的 content 不能被下一轮的 spinner 覆盖。

复现：用户报告「推理中文字出现时覆盖掉已经输出的内容，看不到思考完正式内容」。
根因：_consume_stream 的 finally 块在 tool_spinner.stop() 后写入 \\033[F（光标上移），
      把光标移回 content 行；下一轮 spinner 启动时 \\r 回到行首，覆盖 content。
"""
import io
import re
import sys
from unittest.mock import patch, MagicMock

import abyss.main as main_mod
from tests.test_tool_calls_spinner import _make_tc_delta


def _render_terminal(raw_text):
    """模拟终端渲染，正确处理 \\r \\n \\x1b[K（清行）\\x1b[F/\\x1b[A（光标上移）。

    比 test_tool_calls_spinner._render_terminal 更准确：处理光标上移和清行，
    用于验证多轮 spinner 是否覆盖上一轮内容。
    """
    text = re.sub(r'\x1b\[[0-9;]*m', '', raw_text)  # 只剥 SGR，保留 K/F/A 等控制
    lines = [""]
    cur = 0
    pos = 0
    i = 0
    while i < len(text):
        # \x1b[K 清当前行从光标到行尾
        if text.startswith('\x1b[K', i):
            if cur < len(lines):
                lines[cur] = lines[cur][:pos]
            i += 3
            continue
        # \x1b[F 或 \x1b[A 光标上移一行
        if text.startswith('\x1b[F', i) or text.startswith('\x1b[A', i):
            if cur > 0:
                cur -= 1
            # 上移后 pos 设为该行末尾（模拟真实终端行为：上移不改变列，但我们的场景中
            # 上移后立即 \r 回到行首，所以 pos 值会被后续 \r 覆盖，这里设为 0 更安全）
            pos = 0 if cur < len(lines) else 0
            if cur < len(lines):
                pos = len(lines[cur])
            i += 3
            continue
        ch = text[i]
        if ch == '\r':
            pos = 0
            i += 1
        elif ch == '\n':
            # 补齐到当前行
            if cur >= len(lines):
                lines.append("")
            lines.append("")
            cur += 1
            pos = 0
            i += 1
        else:
            while cur >= len(lines):
                lines.append("")
            line = lines[cur]
            if pos < len(line):
                lines[cur] = line[:pos] + ch + line[pos+1:]
            else:
                lines[cur] = line + ' ' * (pos - len(line)) + ch
            pos += 1
            i += 1
    return lines


def _run_consume_stream(chunks, show_reasoning=True):
    """运行 _consume_stream 并捕获 stdout，返回 (raw_output, visible_lines, result)。"""
    buf = io.StringIO()
    old_stdout = sys.stdout
    sys.stdout = buf
    spinner = main_mod.Spinner("思考中")
    try:
        with patch.object(main_mod, '_ESC', None), \
             patch.object(main_mod.logger, 'api_first_token', MagicMock()), \
             patch.object(main_mod.logger, 'api_response', MagicMock()), \
             patch.object(main_mod.logger, 'stream_reasoning_start', MagicMock()), \
             patch.object(main_mod.logger, 'stream_content_start', MagicMock()), \
             patch.object(main_mod.logger, 'spinner_start', MagicMock()), \
             patch.object(main_mod.logger, 'spinner_stop', MagicMock()), \
             patch('time.sleep', return_value=None):
            spinner.start()
            result = main_mod._consume_stream(iter(chunks), spinner, show_reasoning)
    finally:
        spinner.stop()
        sys.stdout = old_stdout

    raw = buf.getvalue()
    return raw, _render_terminal(raw), result


def _simulate_next_round_spinner(raw_after_round1):
    """将 round1 的原始输出 + 模拟下一轮 spinner 启动 + spinner 停止 + reasoning 输出。

    模拟 _stream_handler 的真实行为：spinner.start() 写 \\r  ⠋ 推理中，spinner.stop() 写 \\r\\x1b[K，
    然后 echo "  思考: ...".
    返回完整的原始输出，用于终端渲染。
    """
    full = raw_after_round1
    # 下一轮 spinner 启动：\r 回到行首 + 写 spinner 帧
    full += "\r  \033[2m⠋ 推理中\033[0m"
    # spinner 停止：\r 回到行首 + \x1b[K 清行
    full += "\r\x1b[K"
    # reasoning 输出
    full += "  \033[2;90m思考: 下一轮推理内容\033[0m"
    return full


class TestContentNotOverwrittenByNextSpinner:
    """验证多轮工具调用中 content 不被下一轮 spinner 覆盖。"""

    def test_content_survives_next_round_spinner(self):
        """Round1: content + tool_calls → Round2 spinner 启动：round1 content 必须存活。

        红：当前 finally 块写 \\033[F，光标上移到 content 行；下一轮 spinner 的 \\r 覆盖 content。
        绿：移除 \\033[F 后，光标停在空白 spinner 行，下一轮 spinner 在空白行写入，content 保留。
        """
        # Round 1: content "✅ 创建成功" + tool_calls
        chunks = [
            {"reasoning_content": None, "content": "✅ 创建成功", "tool_calls": None},
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_read")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=False)

        # 模拟下一轮 spinner 启动 + reasoning 输出
        full_raw = _simulate_next_round_spinner(raw1)
        rendered_lines = _render_terminal(full_raw)

        # ✅ 创建成功 必须在最终渲染结果中存活
        survived = any("✅ 创建成功" in line for line in rendered_lines)
        assert survived, (
            f"Round 1 content 被下一轮 spinner 覆盖了！\n"
            f"渲染行: {rendered_lines}\n"
            f"原始输出: {full_raw!r}"
        )

    def test_reasoning_survives_next_round_spinner(self):
        """Round1: reasoning + tool_calls → Round2 spinner：round1 reasoning 必须存活。"""
        chunks = [
            {"reasoning_content": "先读文件", "content": None, "tool_calls": None},
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_read")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=True)

        full_raw = _simulate_next_round_spinner(raw1)
        rendered_lines = _render_terminal(full_raw)

        survived = any("先读文件" in line for line in rendered_lines)
        assert survived, (
            f"Round 1 reasoning 被下一轮 spinner 覆盖了！\n"
            f"渲染行: {rendered_lines}\n"
            f"原始输出: {full_raw!r}"
        )

    def test_no_cursor_up_in_finally(self):
        """finally 块不应写 \\033[F（光标上移），否则下一轮 spinner 覆盖内容。"""
        chunks = [
            {"reasoning_content": None, "content": "内容", "tool_calls": None},
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_read")]},
        ]
        raw, _lines, _result = _run_consume_stream(chunks, show_reasoning=False)
        # \033[F 或 \x1b[A 都是光标上移
        assert '\x1b[F' not in raw and '\x1b[A' not in raw, (
            f"finally 块写入光标上移，下一轮 spinner 会覆盖内容！\n原始输出: {raw!r}"
        )


def _simulate_tool_ui_after_spinner(raw_after_consume):
    """模拟 _consume_stream 返回后，工具执行 UI 的输出序列。

    真实流程：
    1. _consume_stream finally: tool_spinner.stop() → \\r\\x1b[K
    2. 主循环可能写 \\033[0m（重置颜色，nl=False）
    3. process_tool_calls → _execute_tool_batch → _show_batch_header: \\n + 动画 + \\n
    4. execute_tool → _show_tool_action: "  写入文件: demo.txt"（nl=False）
    5. file_spinner 操作...
    6. _show_tool_result: \\n + "  完成" + \\n
    返回完整原始输出用于终端渲染验证。
    """
    full = raw_after_consume
    # 主循环可能写入的颜色重置
    full += "\033[0m"
    # _show_batch_header: 先换行保护上一行，然后动画，最后落定换行
    full += "\n"
    full += "\r\x1b[K  \033[2m● 工具调用 (第 1 轮 / 100) — 共 1 个\033[0m\n"
    # _show_tool_action: 文件操作先不打动作行（专属spinner），spinner停止后补打
    full += "\r\x1b[K  \033[36m写入文件: demo.txt\033[0m"
    # _show_tool_result: 先换行，再显示结果
    full += "\n  \033[32m完成\033[0m\n"
    return full


class TestBug1ToolPathNotOverwritten:
    """Bug1: 推理中→生成中后不换行，导致写入/编辑/读取的文件地址被覆盖。

    根因：tool_spinner.stop() 后光标停在spinner行行首，_show_batch_header/_show_tool_action
    从行首写\\r覆盖spinner行但未先换行，导致文件路径行与spinner/批次头在同一行互相覆盖。
    修复：finally确保外层spinner已停止；_show_batch_header开头\\n下移到新行；
    tool_spinner停止后光标在清空行的行首，后续_show_batch_header的\\n确保新起一行。
    """

    def test_file_path_visible_after_tool_spinner_with_reasoning(self):
        """reasoning → tool_calls 场景：文件路径'写入文件: demo.txt'必须完整可见。"""
        chunks = [
            {"reasoning_content": "用户要求创建文件，我需要调用file_write。", "content": None, "tool_calls": None},
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_write")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=True)
        full_raw = _simulate_tool_ui_after_spinner(raw1)
        rendered = _render_terminal(full_raw)

        path_found = any("写入文件: demo.txt" in line for line in rendered)
        assert path_found, (
            f"Bug1: 文件路径被覆盖！'写入文件: demo.txt'未在渲染结果中找到。\n"
            f"渲染行: {rendered}\n原始输出: {full_raw!r}"
        )

    def test_file_path_visible_after_tool_spinner_no_reasoning(self):
        """无reasoning直接tool_calls场景：文件路径必须完整可见。"""
        chunks = [
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_write")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=False)
        full_raw = _simulate_tool_ui_after_spinner(raw1)
        rendered = _render_terminal(full_raw)

        path_found = any("写入文件: demo.txt" in line for line in rendered)
        assert path_found, (
            f"Bug1: 文件路径被覆盖！'写入文件: demo.txt'未在渲染结果中找到。\n"
            f"渲染行: {rendered}\n原始输出: {full_raw!r}"
        )

    def test_batch_header_on_own_line(self):
        """工具批次头'工具调用'必须在独立行，不被spinner或文件路径覆盖。"""
        chunks = [
            {"reasoning_content": "先读取", "content": None, "tool_calls": None},
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_read")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=True)
        full_raw = _simulate_tool_ui_after_spinner(raw1)
        rendered = _render_terminal(full_raw)

        header_found = any("工具调用" in line and "写入文件" not in line and "完成" not in line
                          for line in rendered)
        assert header_found, (
            f"Bug1: 批次头和文件路径互相覆盖！\n渲染行: {rendered}\n原始输出: {full_raw!r}"
        )


class TestBug2CJKContentNotTruncated:
    """Bug2: 输出'你好'只显示'好'——第一个CJK字符被spinner的\\r覆盖。

    根因：外层spinner线程在content开始输出后未被正确停止，其\\r回到行首清掉了
    第一个字符；或_stop_outer_spinner()在某些路径下未被调用，spinner线程继续运行。
    修复：finally块始终调用_stop_outer_spinner()确保spinner线程已join退出；
    has_visible只在实际输出可见文本时才置True，确保show_reasoning=False时
    外层spinner在tool_calls到达时被正确停止。
    """

    def test_cjk_hello_fully_displayed_no_reasoning(self):
        """无reasoning场景：content='你好'必须两个字都显示。"""
        chunks = [
            {"reasoning_content": None, "content": "你好", "tool_calls": None},
        ]
        raw, lines, result = _run_consume_stream(chunks, show_reasoning=False)
        content, reasoning, tool_calls, _usage = result

        assert content == "你好", f"content应完整返回'你好'，实际: {content!r}"
        hello_found = any("你好" in line for line in lines)
        assert hello_found, (
            f"Bug2: '你好'被截断！'你'字丢失。\n渲染行: {lines}\n原始输出: {raw!r}"
        )

    def test_cjk_hello_fully_displayed_with_reasoning(self):
        """有reasoning场景：content='你好'必须两个字都显示。"""
        chunks = [
            {"reasoning_content": "思考中", "content": None, "tool_calls": None},
            {"reasoning_content": None, "content": "你好", "tool_calls": None},
        ]
        raw, lines, result = _run_consume_stream(chunks, show_reasoning=True)
        content, reasoning, tool_calls, _usage = result

        assert content == "你好", f"content应完整返回'你好'，实际: {content!r}"
        hello_found = any("你好" in line for line in lines)
        assert hello_found, (
            f"Bug2: '你好'被截断！'你'字丢失。\n渲染行: {lines}\n原始输出: {raw!r}"
        )

    def test_cjk_content_after_tool_calls_fully_displayed(self):
        """tool_calls后content场景：第二轮content'你好'必须完整显示。"""
        chunks = [
            {"reasoning_content": None, "content": None, "tool_calls": [_make_tc_delta(name="file_write")]},
        ]
        raw1, _lines1, _result1 = _run_consume_stream(chunks, show_reasoning=False)

        # 模拟工具执行后，下一轮stream_handler的输出：spinner启动→停止→输出"你好"
        full_raw = raw1
        full_raw += "\n"  # _show_batch_header 的开头换行
        full_raw += "\r\x1b[K  \033[2m● 工具调用 (第 1 轮) — 共 1 个\033[0m\n"
        full_raw += "\r\x1b[K  \033[36m写入文件: demo.txt\033[0m\n  \033[32m完成\033[0m\n"
        # 下一轮stream_handler：新spinner启动，content到达后停止，输出"你好"
        full_raw += "\r\x1b[K"  # spinner.stop()
        full_raw += "\n"  # content前换行
        full_raw += "你好"

        rendered = _render_terminal(full_raw)
        hello_found = any("你好" in line for line in rendered)
        assert hello_found, (
            f"Bug2: 工具调用后输出'你好'被截断！\n渲染行: {rendered}\n原始输出: {full_raw!r}"
        )
