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

覆盖：
- 双写（纯文本 .log + JSONL .jsonl）
- 级别过滤（DEBUG/INFO/WARN/ERROR）
- trace_id / session_id 上下文串联
- 文件分离（主日志 / error / metrics）
- 向后兼容（原 EventLogger API + 模块级转发函数）
- 标准 logging 桥接（修复 file_history/undo/cron_service 日志丢失）
- 沙箱降级（写入失败不崩溃）
- P4 新事件方法（tool_correction/hook_block/token_usage 等）
- 长字段截断
- 线程安全（并发写入）
"""
import gzip
import json
import logging
import os
import threading
from datetime import datetime, timedelta
from pathlib import Path

import pytest

from abyss import logger as logger_module
from abyss.logger import (
    StructuredLogger,
    new_trace_id,
    get_trace_id,
    clear_trace_id,
    set_session_id,
    get_session_id,
    configure_standard_logging,
    _StandardLoggingBridge,
    LEVELS,
)


# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def isolated_logger(tmp_path, monkeypatch):
    """创建隔离的 StructuredLogger 实例，日志写入 tmp_path，不污染真实日志目录。"""
    monkeypatch.setattr(logger_module, "LOG_DIR", tmp_path)
    inst = StructuredLogger()
    # 重置上下文
    clear_trace_id()
    set_session_id("")
    yield inst
    # 清理：关闭文件句柄
    with inst._lock:
        for log_f, jsonl_f in inst._files.values():
            try:
                log_f.close()
                jsonl_f.close()
            except Exception:
                pass
    clear_trace_id()
    set_session_id("")


def read_lines(fp: Path) -> list:
    """读取文件所有行，文件不存在返回空列表。"""
    if not fp.exists():
        return []
    return fp.read_text(encoding="utf-8").splitlines()


def parse_jsonl(fp: Path) -> list:
    """解析 JSONL 文件为 dict 列表。"""
    lines = read_lines(fp)
    return [json.loads(line) for line in lines if line.strip()]


# ---------------------------------------------------------------------------
# 双写测试
# ---------------------------------------------------------------------------
class TestDualWrite:
    """验证纯文本 .log 和 JSONL .jsonl 双写。"""

    def test_text_and_jsonl_both_written(self, isolated_logger, tmp_path):
        isolated_logger.info("test message")
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        assert log_fp.exists(), "纯文本日志未写入"
        assert jsonl_fp.exists(), "JSONL 日志未写入"

    def test_jsonl_format_valid(self, isolated_logger, tmp_path):
        isolated_logger.tool_start("file_read", args='{"path":"/tmp"}')
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert len(records) == 1
        rec = records[0]
        assert rec["cat"] == "TOOL"
        assert rec["event"] == "start"
        assert rec["name"] == "file_read"
        assert rec["args"] == '{"path":"/tmp"}'
        assert "ts" in rec
        assert "level" in rec

    def test_text_format_has_level(self, isolated_logger, tmp_path):
        """验证纯文本格式包含 level 字段（新增）。"""
        isolated_logger.error("something failed")
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 1
        # 格式: ts | LEVEL | CATEGORY | EVENT | payload
        assert "| ERROR |" in lines[0]


# ---------------------------------------------------------------------------
# 级别过滤测试
# ---------------------------------------------------------------------------
class TestLevelFilter:
    """验证 DEBUG/INFO/WARN/ERROR 级别过滤。"""

    def test_default_level_info(self, isolated_logger):
        """默认级别 INFO，DEBUG 不写入。"""
        assert isolated_logger.get_level() == "INFO"
        isolated_logger.debug("debug msg")  # 应被过滤
        isolated_logger.info("info msg")    # 应写入
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = logger_module.LOG_DIR / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 1
        assert "info msg" in lines[0]

    def test_set_level_debug(self, isolated_logger):
        isolated_logger.set_level("DEBUG")
        isolated_logger.debug("debug msg")
        isolated_logger.info("info msg")
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = logger_module.LOG_DIR / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 2

    def test_set_level_warn(self, isolated_logger):
        isolated_logger.set_level("WARN")
        isolated_logger.info("info msg")     # 过滤
        isolated_logger.warning("warn msg")  # 写入
        isolated_logger.error("err msg")     # 写入
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = logger_module.LOG_DIR / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 2

    def test_set_level_error(self, isolated_logger):
        isolated_logger.set_level("ERROR")
        isolated_logger.warning("warn msg")  # 过滤
        isolated_logger.error("err msg")     # 写入
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = logger_module.LOG_DIR / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 1

    def test_spinner_batch_stream_debug_level(self, isolated_logger):
        """SPINNER/BATCH/STREAM 类别默认 DEBUG 级别，INFO 时不写入。"""
        isolated_logger.spinner_start("loading")
        isolated_logger.batch_header(1, 3, 100)
        isolated_logger.stream_content_start()
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = logger_module.LOG_DIR / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 0  # 全部 DEBUG，被过滤


# ---------------------------------------------------------------------------
# trace_id / session_id 测试
# ---------------------------------------------------------------------------
class TestTraceContext:
    """验证 trace_id 和 session_id 上下文串联。"""

    def test_new_trace_id_returns_8char(self):
        tid = new_trace_id()
        assert len(tid) == 8
        assert tid == get_trace_id()

    def test_clear_trace_id(self):
        new_trace_id()
        clear_trace_id()
        assert get_trace_id() == ""

    def test_trace_id_in_jsonl(self, isolated_logger, tmp_path):
        tid = new_trace_id()
        isolated_logger.info("test")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["trace_id"] == tid

    def test_trace_id_thread_isolation(self):
        """trace_id 线程隔离。"""
        results = {}

        def worker(name):
            tid = new_trace_id()
            results[name] = tid
            import time
            time.sleep(0.05)
            assert get_trace_id() == tid  # 线程内不变

        t1 = threading.Thread(target=worker, args=("t1",))
        t2 = threading.Thread(target=worker, args=("t2",))
        t1.start()
        t2.start()
        t1.join()
        t2.join()
        assert results["t1"] != results["t2"]  # 不同线程不同 trace_id

    def test_session_id_in_jsonl(self, isolated_logger, tmp_path):
        set_session_id("test-session-123")
        isolated_logger.info("test")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["session_id"] == "test-session-123"

    def test_no_trace_id_when_not_set(self, isolated_logger, tmp_path):
        """未设置 trace_id 时，JSONL 不含 trace_id 字段。"""
        clear_trace_id()
        isolated_logger.info("test")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert "trace_id" not in records[0]


# ---------------------------------------------------------------------------
# 文件分离测试
# ---------------------------------------------------------------------------
class TestFileSeparation:
    """验证主日志 / error / metrics 文件分离。"""

    def test_error_file_only_warn_error(self, isolated_logger, tmp_path):
        isolated_logger.info("info msg")     # 仅主日志
        isolated_logger.warning("warn msg")  # 主日志 + error
        isolated_logger.error("err msg")     # 主日志 + error
        today = datetime.now().strftime("%Y-%m-%d")
        main_fp = tmp_path / f"abyss_{today}.log"
        err_fp = tmp_path / f"error_{today}.log"
        assert len(read_lines(main_fp)) == 3
        assert len(read_lines(err_fp)) == 2  # warn + error

    def test_metrics_file_only_performance(self, isolated_logger, tmp_path):
        """metrics 文件仅含 API/TOOL 性能事件。"""
        isolated_logger.tool_start("file_read", args="{}")  # 非指标
        isolated_logger.tool_end("file_read", True, 100, out_len=50)  # 指标
        isolated_logger.api_response(api_ms=500, content_len=100)     # 指标
        isolated_logger.info("plain info")  # 非指标
        today = datetime.now().strftime("%Y-%m-%d")
        metrics_fp = tmp_path / f"metrics_{today}.log"
        lines = read_lines(metrics_fp)
        assert len(lines) == 2  # tool_end + api_response

    def test_error_jsonl_file(self, isolated_logger, tmp_path):
        isolated_logger.error("err")
        today = datetime.now().strftime("%Y-%m-%d")
        err_jsonl_fp = tmp_path / f"error_{today}.jsonl"
        assert err_jsonl_fp.exists()
        records = parse_jsonl(err_jsonl_fp)
        assert len(records) == 1
        assert records[0]["level"] == "ERROR"

    def test_metrics_jsonl_file(self, isolated_logger, tmp_path):
        isolated_logger.tool_end("file_read", True, 100)
        today = datetime.now().strftime("%Y-%m-%d")
        metrics_jsonl_fp = tmp_path / f"metrics_{today}.jsonl"
        assert metrics_jsonl_fp.exists()
        records = parse_jsonl(metrics_jsonl_fp)
        assert len(records) == 1
        assert records[0]["cat"] == "TOOL"


# ---------------------------------------------------------------------------
# 向后兼容测试
# ---------------------------------------------------------------------------
class TestBackwardCompat:
    """验证原 EventLogger API 和模块级转发函数。"""

    def test_module_level_functions(self, tmp_path, monkeypatch):
        """模块级转发函数（logger.info / logger.error 等）正常工作。"""
        monkeypatch.setattr(logger_module, "LOG_DIR", tmp_path)
        # 替换全局单例
        original = logger_module._logger
        new_inst = StructuredLogger()
        monkeypatch.setattr(logger_module, "_logger", new_inst)
        try:
            logger_module.info("module info")
            logger_module.warning("module warn")
            logger_module.error("module err")
            logger_module.tool_start("file_read", args="{}")
            logger_module.tool_end("file_read", True, 50, out_len=100)
            logger_module.session_start(model="test", cwd="/tmp")
            logger_module.session_end("test")

            today = datetime.now().strftime("%Y-%m-%d")
            log_fp = tmp_path / f"abyss_{today}.log"
            lines = read_lines(log_fp)
            assert len(lines) == 7  # 全部写入（含 DEBUG 级别的 tool_start）
        finally:
            with new_inst._lock:
                for log_f, jsonl_f in new_inst._files.values():
                    try:
                        log_f.close()
                        jsonl_f.close()
                    except Exception:
                        pass

    def test_original_event_logger_api(self, isolated_logger, tmp_path):
        """原 EventLogger 方法签名兼容（session_start/tool_start/tool_end 等）。"""
        isolated_logger.session_start(model="deepseek-v4", cwd="/tmp")
        isolated_logger.user_input("hello")
        isolated_logger.slash_command("/help")
        isolated_logger.api_request(model="deepseek-v4", msgs=5, tools=8, thinking=True)
        isolated_logger.api_first_token(200)
        isolated_logger.api_response(api_ms=500, content_len=100, reasoning_len=50, tool_calls=2)
        isolated_logger.api_error("timeout")
        isolated_logger.api_retry(1, 0.5, "rate_limit")
        isolated_logger.tool_start("file_read", args='{"path":"/tmp"}')
        isolated_logger.tool_end("file_read", True, 50, out_len=100)
        isolated_logger.spinner_start("loading")
        isolated_logger.spinner_stop()
        isolated_logger.batch_header(1, 3, 100)
        isolated_logger.stream_content_start()
        isolated_logger.stream_reasoning_start()
        isolated_logger.keyboard_interrupt()
        isolated_logger.eof()
        isolated_logger.error("err", exc_info="traceback")
        isolated_logger.warning("warn")
        isolated_logger.session_end("normal")

        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        # INFO 级别：SPINNER/BATCH/STREAM 是 DEBUG 被过滤
        # api_retry 是 WARN，keyboard_interrupt/eof 是 WARN，error 是 ERROR，warning 是 WARN
        # 其余是 INFO
        assert len(lines) >= 14  # 至少 14 条 INFO+


# ---------------------------------------------------------------------------
# 标准 logging 桥接测试
# ---------------------------------------------------------------------------
class TestStandardLoggingBridge:
    """验证标准 logging 桥接（修复 file_history/undo/cron_service 日志丢失）。"""

    def test_bridge_forwards_to_structured_logger(self, isolated_logger, tmp_path, monkeypatch):
        """标准 logging 记录转发到 StructuredLogger。"""
        monkeypatch.setattr(logger_module, "_logger", isolated_logger)
        bridge = _StandardLoggingBridge()
        record = logging.LogRecord(
            name="abyss.history.file_history",
            level=logging.WARNING,
            pathname="test.py",
            lineno=100,
            msg="FileHistory: record failed for /tmp/test.py: OSError",
            args=(),
            exc_info=None,
        )
        bridge.emit(record)
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 1
        assert "FileHistory: record failed" in lines[0]
        assert "[abyss.history.file_history]" in lines[0]

    def test_configure_standard_logging_no_duplicate(self):
        """configure_standard_logging 不重复添加 handler。"""
        root = logging.getLogger()
        original_handlers = list(root.handlers)
        try:
            configure_standard_logging()
            count1 = sum(1 for h in root.handlers if isinstance(h, _StandardLoggingBridge))
            configure_standard_logging()  # 再次调用
            count2 = sum(1 for h in root.handlers if isinstance(h, _StandardLoggingBridge))
            assert count1 == 1
            assert count2 == 1  # 不重复
        finally:
            root.handlers = original_handlers

    def test_standard_logging_error_level(self, isolated_logger, tmp_path, monkeypatch):
        monkeypatch.setattr(logger_module, "_logger", isolated_logger)
        bridge = _StandardLoggingBridge()
        record = logging.LogRecord(
            name="abyss.tools.cron_service",
            level=logging.ERROR,
            pathname="test.py",
            lineno=228,
            msg="CronTaskService: persist failed: OSError",
            args=(),
            exc_info=None,
        )
        bridge.emit(record)
        today = datetime.now().strftime("%Y-%m-%d")
        err_fp = tmp_path / f"error_{today}.log"
        lines = read_lines(err_fp)
        assert len(lines) == 1
        assert "CronTaskService: persist failed" in lines[0]


# ---------------------------------------------------------------------------
# 沙箱降级测试
# ---------------------------------------------------------------------------
class TestSandboxDegradation:
    """验证写入失败时不崩溃，降级禁用。"""

    def test_disabled_logger_no_write(self, tmp_path):
        """_disabled=True 时不写入。"""
        inst = StructuredLogger()
        inst._disabled = True
        inst.info("should not write")
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        assert not log_fp.exists()

    def test_write_failure_disables_logger(self, monkeypatch):
        """写入 OSError 时设置 _disabled。"""
        inst = StructuredLogger()
        # 模拟 _write_to 抛 OSError
        def boom(kind, text, jsonl):
            raise OSError("Bad file descriptor")
        inst._write_to = boom
        # 触发写入
        inst.info("trigger failure")
        assert inst._disabled is True


# ---------------------------------------------------------------------------
# P4 新事件方法测试
# ---------------------------------------------------------------------------
class TestNewEventMethods:
    """验证 P4 新增事件方法。"""

    def test_tool_correction(self, isolated_logger, tmp_path):
        isolated_logger.tool_correction("file_edit", 1, "未找到匹配的旧内容")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["cat"] == "TOOL"
        assert records[0]["event"] == "correction"
        assert records[0]["tool"] == "file_edit"
        assert records[0]["attempt"] == 1

    def test_hook_block(self, isolated_logger, tmp_path):
        isolated_logger.hook_block("PreToolUse", "shell_exec", "危险命令")
        today = datetime.now().strftime("%Y-%m-%d")
        err_fp = tmp_path / f"error_{today}.log"  # WARN 级别写入 error
        lines = read_lines(err_fp)
        assert len(lines) == 1
        assert "PreToolUse" in lines[0]
        assert "shell_exec" in lines[0]

    def test_token_usage(self, isolated_logger, tmp_path):
        isolated_logger.token_usage(1000, 500, 1500)
        today = datetime.now().strftime("%Y-%m-%d")
        metrics_fp = tmp_path / f"metrics_{today}.log"
        lines = read_lines(metrics_fp)
        assert len(lines) == 1
        assert "prompt_tokens=1000" in lines[0]
        assert "total_tokens=1500" in lines[0]

    def test_compress(self, isolated_logger, tmp_path):
        isolated_logger.compress(20, 10, 5000, 2500, "truncation")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["cat"] == "CONTEXT"
        assert records[0]["event"] == "compress"
        assert records[0]["before_msgs"] == 20
        assert records[0]["after_msgs"] == 10

    def test_circuit_breaker_warn_level(self, isolated_logger, tmp_path):
        isolated_logger.circuit_breaker("OPEN", failure_count=5, reason="timeout")
        today = datetime.now().strftime("%Y-%m-%d")
        err_fp = tmp_path / f"error_{today}.log"
        assert len(read_lines(err_fp)) == 1

    def test_command_use(self, isolated_logger, tmp_path):
        isolated_logger.command_use("/help")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["cat"] == "COMMAND"
        assert records[0]["command"] == "/help"

    def test_subagent_run(self, isolated_logger, tmp_path):
        isolated_logger.subagent_run("search code", 1500, True)
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["cat"] == "AGENT"
        assert records[0]["duration_ms"] == 1500

    def test_curator(self, isolated_logger, tmp_path):
        isolated_logger.curator("archive", "skill:search", "low usage")
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        assert records[0]["cat"] == "AGENT"
        assert records[0]["action"] == "archive"


# ---------------------------------------------------------------------------
# 长字段截断测试
# ---------------------------------------------------------------------------
class TestPayloadTruncation:
    """验证长字段截断。"""

    def test_long_string_truncated_in_jsonl(self, isolated_logger, tmp_path):
        long_str = "x" * 5000
        isolated_logger.info(long_str)
        today = datetime.now().strftime("%Y-%m-%d")
        jsonl_fp = tmp_path / f"abyss_{today}.jsonl"
        records = parse_jsonl(jsonl_fp)
        msg = records[0]["message"]
        assert len(msg) < 5000
        assert "...[" in msg  # 截断标记

    def test_long_string_truncated_in_text(self, isolated_logger, tmp_path):
        long_str = "y" * 5000
        isolated_logger.info(long_str)
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines[0]) < 5000
        assert "...[" in lines[0]


# ---------------------------------------------------------------------------
# 线程安全测试
# ---------------------------------------------------------------------------
class TestThreadSafety:
    """验证并发写入线程安全（_rotate_if_needed 在锁内）。"""

    def test_concurrent_writes_no_crash(self, isolated_logger, tmp_path):
        """多线程并发写入不崩溃。"""
        errors = []

        def worker(tid):
            try:
                for i in range(20):
                    isolated_logger.info(f"thread-{tid} msg-{i}")
            except Exception as e:
                errors.append(e)

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

        assert not errors, f"并发写入出错: {errors}"
        today = datetime.now().strftime("%Y-%m-%d")
        log_fp = tmp_path / f"abyss_{today}.log"
        lines = read_lines(log_fp)
        assert len(lines) == 100  # 5 线程 × 20 条


# ---------------------------------------------------------------------------
# 旧日志压缩测试
# ---------------------------------------------------------------------------
class TestOldLogCompression:
    """验证旧日志 gzip 压缩。"""

    def test_compress_yesterday_log(self, tmp_path, monkeypatch):
        """昨天的 .log/.jsonl 被压缩为 .gz。"""
        monkeypatch.setattr(logger_module, "LOG_DIR", tmp_path)
        yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
        # 创建昨天的日志文件
        old_log = tmp_path / f"abyss_{yesterday}.log"
        old_jsonl = tmp_path / f"abyss_{yesterday}.jsonl"
        old_log.write_text("old log content\n", encoding="utf-8")
        old_jsonl.write_text('{"ts":"old"}\n', encoding="utf-8")

        inst = StructuredLogger()
        inst.info("trigger rotate")  # 触发 _rotate_if_needed → _compress_old

        assert not old_log.exists(), "昨天 .log 未被压缩"
        assert not old_jsonl.exists(), "昨天 .jsonl 未被压缩"
        assert (tmp_path / f"abyss_{yesterday}.log.gz").exists()
        assert (tmp_path / f"abyss_{yesterday}.jsonl.gz").exists()

        # 验证 gz 内容可解压
        with gzip.open(tmp_path / f"abyss_{yesterday}.log.gz", "rt", encoding="utf-8") as f:
            assert "old log content" in f.read()


# ---------------------------------------------------------------------------
# 文件名日期提取测试
# ---------------------------------------------------------------------------
class TestExtractDate:
    """验证 _extract_date 从各种文件名提取日期。"""

    def test_abyss_log(self):
        assert StructuredLogger._extract_date("abyss_2026-07-26.log") == "2026-07-26"

    def test_error_jsonl(self):
        assert StructuredLogger._extract_date("error_2026-07-26.jsonl") == "2026-07-26"

    def test_metrics_gz(self):
        assert StructuredLogger._extract_date("metrics_2026-07-25.log.gz") == "2026-07-25"

    def test_jsonl_gz(self):
        assert StructuredLogger._extract_date("abyss_2026-07-25.jsonl.gz") == "2026-07-25"

    def test_unknown_format(self):
        assert StructuredLogger._extract_date("random.txt") == ""
        # 注：_extract_date 只负责提取，不验证日期格式（由调用方 strptime 验证）
        # abyss_invalid.log 匹配前缀+后缀，返回 "invalid" 是预期行为
