# -*- coding: utf-8 -*-
"""ToolCallTracker 单元测试

覆盖：
- ToolCallRecord.create() args_hash 计算
- record() 基本记录
- is_window_all_failed() 滑动窗口
- is_same_error_repeated() 同错误重复
- consecutive_errors() 计数
- recent() / total_count() / reset() / get_status()
- recovery_relevant 标志位
- FIFO 淘汰
- 线程安全
- 默认单例
"""
import json
import threading
import time

import pytest

from abyss.tools.tracking import (
    ToolCallRecord,
    ToolCallTracker,
    get_default_tracker,
    reset_default_tracker,
)


# ---------------- 工具函数 ----------------

def make_args(**kwargs):
    """构造工具参数 dict。"""
    return dict(kwargs)


# ---------------- ToolCallRecord.create() ----------------

class TestToolCallRecordCreate:
    def test_args_hash_stable_for_same_args(self):
        """相同参数（顺序不同）应生成相同 hash。"""
        r1 = ToolCallRecord.create("file_edit", {"path": "a.py", "old": "x"}, False)
        r2 = ToolCallRecord.create("file_edit", {"old": "x", "path": "a.py"}, False)
        assert r1.args_hash == r2.args_hash

    def test_args_hash_differs_for_different_args(self):
        """不同参数应生成不同 hash。"""
        r1 = ToolCallRecord.create("file_edit", {"path": "a.py"}, False)
        r2 = ToolCallRecord.create("file_edit", {"path": "b.py"}, False)
        assert r1.args_hash != r2.args_hash

    def test_args_hash_for_unhashable_args(self):
        """不可序列化的参数应回退到 'unhashable'。"""
        class Unserializable:
            pass

        # 直接传入不可序列化对象
        r = ToolCallRecord.create(
            "shell_exec", {"obj": Unserializable()}, False
        )
        # 因 json.dumps 默认不可序列化会抛 TypeError
        assert r.args_hash == "unhashable"

    def test_create_with_all_fields(self):
        """完整字段创建应正确赋值。"""
        r = ToolCallRecord.create(
            "file_edit", {"path": "a.py"}, False,
            error="file not found", error_type="FileNotFoundError",
            recovery_relevant=False,
        )
        assert r.tool_name == "file_edit"
        assert r.success is False
        assert r.error_message == "file not found"
        assert r.error_type == "FileNotFoundError"
        assert r.recovery_relevant is False
        assert r.timestamp > 0


# ---------------- 基础 record() ----------------

class TestRecord:
    def test_record_success_increments_total(self):
        tracker = ToolCallTracker()
        tracker.record("file_read", {"path": "a.py"}, True)
        assert tracker.total_count() == 1

    def test_record_failure_increments_total(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="not found")
        assert tracker.total_count() == 1

    def test_record_success_resets_consecutive_errors(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err1")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err2")
        assert tracker.consecutive_errors() == 2
        tracker.record("file_read", {"path": "a.py"}, True)
        assert tracker.consecutive_errors() == 0


# ---------------- is_window_all_failed() ----------------

class TestWindowAllFailed:
    def test_empty_tracker_returns_false(self):
        tracker = ToolCallTracker()
        assert tracker.is_window_all_failed(3) is False

    def test_insufficient_history_returns_false(self):
        """历史不足 window_size 时返回 False。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.is_window_all_failed(3) is False

    def test_all_failed_window_true(self):
        """最近 N 次全失败返回 True。"""
        tracker = ToolCallTracker()
        for i in range(3):
            tracker.record("file_edit", {"path": f"a{i}.py"}, False, error=f"err{i}")
        assert tracker.is_window_all_failed(3) is True

    def test_mixed_success_failure_returns_false(self):
        """最近 N 次中有成功返回 False。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_read", {"path": "a.py"}, True)
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.is_window_all_failed(3) is False

    def test_recovery_relevant_false_excluded(self):
        """recovery_relevant=False 的记录不计入全失败判定。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="blocked", recovery_relevant=False)
        tracker.record("file_edit", {"path": "a.py"}, False, error="blocked", recovery_relevant=False)
        tracker.record("file_edit", {"path": "a.py"}, False, error="blocked", recovery_relevant=False)
        # 全是预期终态，不算"需要恢复"
        assert tracker.is_window_all_failed(3) is False

    def test_window_size_5(self):
        """window_size=5 时的判定。"""
        tracker = ToolCallTracker()
        for i in range(4):
            tracker.record("file_edit", {"path": f"a{i}.py"}, False, error="err")
        # 4 次不足 5
        assert tracker.is_window_all_failed(5) is False
        tracker.record("file_edit", {"path": "a4.py"}, False, error="err")
        assert tracker.is_window_all_failed(5) is True


# ---------------- is_same_error_repeated() ----------------

class TestSameErrorRepeated:
    def test_empty_tracker_returns_false(self):
        tracker = ToolCallTracker()
        assert tracker.is_same_error_repeated(3) is False

    def test_threshold_zero_returns_false(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.is_same_error_repeated(0) is False

    def test_latest_success_returns_false(self):
        """最新一次成功，不算重复失败。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_read", {"path": "a.py"}, True)
        assert tracker.is_same_error_repeated(3) is False

    def test_same_error_3_times_true(self):
        tracker = ToolCallTracker()
        for _ in range(3):
            tracker.record("file_edit", {"path": "a.py"}, False, error="same error")
        assert tracker.is_same_error_repeated(3) is True

    def test_different_errors_returns_false(self):
        """不同 error_message 连续出现不算重复。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err1")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err2")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err3")
        assert tracker.is_same_error_repeated(3) is False

    def test_success_breaks_continuity(self):
        """成功记录打断连续性。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_read", {"path": "a.py"}, True)  # 打断
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        # 最新一次 err 但前面是 success，连续数=1 < 3
        assert tracker.is_same_error_repeated(3) is False

    def test_threshold_2(self):
        """threshold=2 时的判定。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.is_same_error_repeated(2) is True
        assert tracker.is_same_error_repeated(3) is False


# ---------------- consecutive_errors() ----------------

class TestConsecutiveErrors:
    def test_initial_zero(self):
        tracker = ToolCallTracker()
        assert tracker.consecutive_errors() == 0

    def test_increment_on_failure(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.consecutive_errors() == 1
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        assert tracker.consecutive_errors() == 2

    def test_reset_on_success(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_read", {"path": "a.py"}, True)
        assert tracker.consecutive_errors() == 0

    def test_recovery_relevant_false_still_increments(self):
        """recovery_relevant=False 的失败仍参与计数（不影响 consecutive_errors）。"""
        tracker = ToolCallTracker()
        tracker.record(
            "file_edit", {"path": "a.py"}, False, error="blocked",
            recovery_relevant=False,
        )
        assert tracker.consecutive_errors() == 1


# ---------------- recent() / total_count() / reset() ----------------

class TestRecentAndReset:
    def test_recent_returns_copy(self):
        """recent() 返回副本，修改不影响内部状态。"""
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        recent = tracker.recent(5)
        assert len(recent) == 1
        recent.clear()
        # 内部未受影响
        assert tracker.total_count() == 1

    def test_recent_n_returns_last_n(self):
        tracker = ToolCallTracker()
        for i in range(10):
            tracker.record("file_edit", {"path": f"a{i}.py"}, True)
        recent = tracker.recent(3)
        assert len(recent) == 3
        # 应是最后 3 个
        assert recent[0].tool_name == "file_edit"
        # args_hash 应该是 a7/a8/a9
        assert "a7" not in recent[0].args_hash  # 仅检查长度即可

    def test_reset_clears_all(self):
        tracker = ToolCallTracker()
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.reset()
        assert tracker.total_count() == 0
        assert tracker.consecutive_errors() == 0
        assert tracker.is_window_all_failed(3) is False

    def test_total_count(self):
        tracker = ToolCallTracker()
        for i in range(5):
            tracker.record("file_edit", {"path": f"a{i}.py"}, True)
        assert tracker.total_count() == 5


# ---------------- FIFO 淘汰 ----------------

class TestFIFOEviction:
    def test_max_history_default_100(self):
        """默认 max_history=100，超过时 FIFO 淘汰。"""
        tracker = ToolCallTracker(max_history=5)
        for i in range(10):
            tracker.record("file_edit", {"path": f"a{i}.py"}, True)
        # 仅保留最后 5 条
        assert tracker.total_count() == 5
        recent = tracker.recent(10)
        # 第一条应该是 a5（0-4 被淘汰）
        # 通过 args_hash 验证（仅检查数量足够）
        assert len(recent) == 5

    def test_fifo_does_not_affect_consecutive_errors(self):
        """FIFO 淘汰不应重置 consecutive_errors。"""
        tracker = ToolCallTracker(max_history=3)
        for i in range(5):
            tracker.record("file_edit", {"path": f"a{i}.py"}, False, error="err")
        # 5 次失败但 max_history=3，consecutive_errors 应仍是 5
        assert tracker.consecutive_errors() == 5
        assert tracker.total_count() == 3  # 历史被淘汰到 3


# ---------------- get_status() ----------------

class TestGetStatus:
    def test_empty_status(self):
        tracker = ToolCallTracker()
        status = tracker.get_status()
        assert status["total_calls"] == 0
        assert status["success_count"] == 0
        assert status["fail_count"] == 0
        assert status["consecutive_errors"] == 0
        assert status["window_all_failed"] is False
        assert status["same_error_repeated"] is False

    def test_mixed_status(self):
        tracker = ToolCallTracker()
        tracker.record("file_read", {"path": "a.py"}, True)
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        tracker.record("file_edit", {"path": "a.py"}, False, error="err")
        status = tracker.get_status()
        assert status["total_calls"] == 3
        assert status["success_count"] == 1
        assert status["fail_count"] == 2
        assert status["consecutive_errors"] == 2
        # 3 次中只有 2 次失败，window_all_failed=False
        assert status["window_all_failed"] is False


# ---------------- 默认单例 ----------------

class TestDefaultSingleton:
    def setup_method(self):
        reset_default_tracker()

    def teardown_method(self):
        reset_default_tracker()

    def test_get_default_tracker_returns_same_instance(self):
        t1 = get_default_tracker()
        t2 = get_default_tracker()
        assert t1 is t2

    def test_reset_default_tracker_creates_new(self):
        t1 = get_default_tracker()
        reset_default_tracker()
        t2 = get_default_tracker()
        assert t1 is not t2

    def test_default_tracker_is_usable(self):
        tracker = get_default_tracker()
        tracker.record("file_read", {"path": "a.py"}, True)
        assert tracker.total_count() == 1
        assert tracker.consecutive_errors() == 0


# ---------------- 线程安全 ----------------

class TestThreadSafety:
    def test_concurrent_records_no_loss(self):
        """并发记录不应丢失（线程安全）。"""
        tracker = ToolCallTracker(max_history=1000)
        N_THREADS = 10
        N_PER_THREAD = 50

        def worker(tid):
            for i in range(N_PER_THREAD):
                tracker.record(
                    "file_edit", {"tid": tid, "i": i}, True
                )

        threads = [threading.Thread(target=worker, args=(t,)) for t in range(N_THREADS)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert tracker.total_count() == N_THREADS * N_PER_THREAD

    def test_concurrent_window_check_safe(self):
        """并发调用 is_window_all_failed 不应抛异常。"""
        tracker = ToolCallTracker()
        for i in range(5):
            tracker.record("file_edit", {"path": f"a{i}.py"}, False, error="err")

        errors = []

        def worker():
            try:
                for _ in range(100):
                    tracker.is_window_all_failed(3)
                    tracker.is_same_error_repeated(3)
                    tracker.consecutive_errors()
                    tracker.recent(5)
                    tracker.get_status()
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=worker) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        assert errors == []
