# -*- coding: utf-8 -*-
"""
test_cache_manager.py — cache_manager 模块单元测试

覆盖方法：
  _get_cache_key / get / set / clear_expired / clear_all
  get_stats / check_and_clean_if_needed

被测模块：skill/code/web/cache_manager.py
所有测试用 tmp_path 隔离，不触碰真实 cache 目录。
"""

import json
import time
from pathlib import Path

import cache_manager
from cache_manager import CacheManager


# ============================================================
# _get_cache_key
# ============================================================

class TestGetCacheKey:
    """缓存键生成（MD5 哈希）"""

    def test_same_args_same_key(self):
        """相同参数生成相同 key"""
        c = CacheManager("./_tk")
        k1 = c._get_cache_key("proj", "sys", "apk", "query")
        k2 = c._get_cache_key("proj", "sys", "apk", "query")
        assert k1 == k2
        # MD5 hex 长度 32
        assert len(k1) == 32

    def test_different_args_different_key(self):
        """任一参数不同则 key 不同"""
        c = CacheManager("./_tk")
        k = c._get_cache_key("proj", "sys", "apk", "query")
        assert k != c._get_cache_key("proj2", "sys", "apk", "query")
        assert k != c._get_cache_key("proj", "sys2", "apk", "query")
        assert k != c._get_cache_key("proj", "sys", "apk2", "query")
        assert k != c._get_cache_key("proj", "sys", "apk", "query2")

    def teardown_method(self):
        import shutil
        shutil.rmtree("./_tk", ignore_errors=True)


# ============================================================
# get / set 基本读写
# ============================================================

class TestGetSet:
    """缓存读写主路径"""

    def test_set_then_get_hit(self, tmp_path):
        """写入后读取命中"""
        c = CacheManager(tmp_path, expire_hours=24)
        c.set("厦门银行", "标准版", "门口屏", "MQTT失败", "响应内容", [{"rank": 1}])
        data = c.get("厦门银行", "标准版", "门口屏", "MQTT失败")
        assert data is not None
        assert data["response"] == "响应内容"
        assert data["project_name"] == "厦门银行"
        assert data["matched_cases"] == [{"rank": 1}]
        assert "timestamp" in data
        assert "datetime" in data

    def test_get_miss_returns_none(self, tmp_path):
        """未写入的 key 返回 None"""
        c = CacheManager(tmp_path)
        assert c.get("no", "such", "key", "query") is None

    def test_get_expired_returns_none_and_deletes_file(self, tmp_path):
        """过期缓存返回 None 并删除文件"""
        c = CacheManager(tmp_path, expire_hours=1)
        c.set("p", "s", "a", "q", "resp", [])
        # 找到缓存文件，回写一个过期时间戳
        key = c._get_cache_key("p", "s", "a", "q")
        cache_file = tmp_path / f"{key}.json"
        data = json.loads(cache_file.read_text(encoding="utf-8"))
        data["timestamp"] = time.time() - 7200  # 2 小时前（过期）
        cache_file.write_text(json.dumps(data), encoding="utf-8")
        # 读取应返回 None
        assert c.get("p", "s", "a", "q") is None
        # 文件应被删除
        assert not cache_file.exists()

    def test_get_corrupt_json_returns_none(self, tmp_path):
        """缓存文件 JSON 损坏：返回 None，不抛异常"""
        c = CacheManager(tmp_path)
        key = c._get_cache_key("p", "s", "a", "q")
        (tmp_path / f"{key}.json").write_text("{not valid json", encoding="utf-8")
        assert c.get("p", "s", "a", "q") is None

    def test_get_directory_as_file_returns_none(self, tmp_path):
        """缓存路径被目录占用（OSError 分支）：返回 None，不抛异常"""
        c = CacheManager(tmp_path)
        key = c._get_cache_key("p", "s", "a", "q")
        # 在缓存文件路径创建目录而非文件
        (tmp_path / f"{key}.json").mkdir()
        assert c.get("p", "s", "a", "q") is None

    def test_set_unserializable_object_no_raise(self, tmp_path):
        """set 传入含不可序列化对象：不抛异常（记日志）"""
        c = CacheManager(tmp_path)
        # 含 set 的对象会触发 TypeError（json.dump 无法序列化 set）
        obj_with_set = {"a_set": {1, 2, 3}}
        c.set("p", "s", "a", "q", obj_with_set, [])  # 不应抛异常
        # 写入失败，读取应为 None
        assert c.get("p", "s", "a", "q") is None

    def test_set_to_readonly_dir_no_raise(self, tmp_path):
        """set 写入失败（OSError 分支）：不抛异常（记日志）

        构造方式：把 cache_file 路径占用为目录，open(w) 会抛 IsADirectoryError(OSError 子类)
        """
        c = CacheManager(tmp_path)
        key = c._get_cache_key("p", "s", "a", "q")
        (tmp_path / f"{key}.json").mkdir()  # 占位为目录
        c.set("p", "s", "a", "q", "resp", [])  # 不应抛异常
        # 因写入失败，目录仍在
        assert (tmp_path / f"{key}.json").is_dir()


# ============================================================
# clear_expired
# ============================================================

class TestClearExpired:
    """过期缓存清理"""

    def test_clears_only_expired(self, tmp_path):
        """只清理过期文件，未过期保留"""
        c = CacheManager(tmp_path, expire_hours=1)
        # 写入 3 个缓存
        c.set("p1", "s", "a", "q1", "r", [])
        c.set("p2", "s", "a", "q2", "r", [])
        c.set("p3", "s", "a", "q3", "r", [])
        # 把前两个改为过期
        for proj in ("p1", "p2"):
            key = c._get_cache_key(proj, "s", "a", f"q{proj[-1]}")
            cf = tmp_path / f"{key}.json"
            data = json.loads(cf.read_text(encoding="utf-8"))
            data["timestamp"] = time.time() - 7200
            cf.write_text(json.dumps(data), encoding="utf-8")
        cleared = c.clear_expired()
        assert cleared == 2
        # 未过期的 p3 还在
        assert c.get("p3", "s", "a", "q3") is not None
        # 过期的已删
        assert c.get("p1", "s", "a", "q1") is None
        assert c.get("p2", "s", "a", "q2") is None

    def test_clear_expired_skips_corrupt_files(self, tmp_path):
        """清理时遇损坏文件：跳过不中断"""
        c = CacheManager(tmp_path, expire_hours=1)
        # 一个正常过期
        c.set("p1", "s", "a", "q1", "r", [])
        key1 = c._get_cache_key("p1", "s", "a", "q1")
        data = json.loads((tmp_path / f"{key1}.json").read_text(encoding="utf-8"))
        data["timestamp"] = time.time() - 7200
        (tmp_path / f"{key1}.json").write_text(json.dumps(data), encoding="utf-8")
        # 一个损坏文件
        key2 = c._get_cache_key("p2", "s", "a", "q2")
        (tmp_path / f"{key2}.json").write_text("{broken", encoding="utf-8")
        # 不应抛异常
        cleared = c.clear_expired()
        assert cleared == 1  # 只清理了正常的过期文件

    def test_clear_expired_empty_dir(self, tmp_path):
        """空目录清理返回 0"""
        c = CacheManager(tmp_path)
        assert c.clear_expired() == 0


# ============================================================
# clear_all
# ============================================================

class TestClearAll:
    """清空全部缓存"""

    def test_clear_all_removes_everything(self, tmp_path):
        c = CacheManager(tmp_path)
        c.set("p1", "s", "a", "q1", "r", [])
        c.set("p2", "s", "a", "q2", "r", [])
        cleared = c.clear_all()
        assert cleared == 2
        assert list(tmp_path.glob("*.json")) == []

    def test_clear_all_empty_returns_zero(self, tmp_path):
        c = CacheManager(tmp_path)
        assert c.clear_all() == 0


# ============================================================
# get_stats
# ============================================================

class TestGetStats:
    """缓存统计"""

    def test_stats_empty(self, tmp_path):
        """空缓存统计"""
        c = CacheManager(tmp_path)
        stats = c.get_stats()
        assert stats["total_files"] == 0
        assert stats["total_size_mb"] == 0.0
        assert stats["oldest"] is None
        assert stats["newest"] is None

    def test_stats_with_files(self, tmp_path):
        """有文件时统计正确"""
        c = CacheManager(tmp_path)
        c.set("p1", "s", "a", "q1", "r", [])
        time.sleep(0.01)
        c.set("p2", "s", "a", "q2", "r", [])
        stats = c.get_stats()
        assert stats["total_files"] == 2
        assert stats["total_size_mb"] >= 0.0
        assert stats["oldest"] is not None
        assert stats["newest"] is not None
        # newest >= oldest
        assert stats["newest"] >= stats["oldest"]

    def test_stats_skips_corrupt_files(self, tmp_path):
        """统计遇损坏文件：跳过，不影响文件计数"""
        c = CacheManager(tmp_path)
        c.set("p1", "s", "a", "q1", "r", [])
        # 加一个损坏文件
        (tmp_path / "corrupt.json").write_text("{broken", encoding="utf-8")
        # 不应抛异常
        stats = c.get_stats()
        # 两个 .json 文件都计入 total_files（glob 不解析内容）
        assert stats["total_files"] == 2


# ============================================================
# check_and_clean_if_needed
# ============================================================

class TestCheckAndCleanIfNeeded:
    """超限清理"""

    def test_under_limit_no_cleanup(self, tmp_path):
        """未超限不清理"""
        c = CacheManager(tmp_path, expire_hours=24, max_size_mb=100)
        c.set("p1", "s", "a", "q1", "r", [])
        files_before = len(list(tmp_path.glob("*.json")))
        c.check_and_clean_if_needed()
        files_after = len(list(tmp_path.glob("*.json")))
        assert files_after == files_before

    def test_over_limit_cleans_oldest(self, tmp_path):
        """超限时清理最旧的 20%"""
        # max_size 设极小，写入少量数据即超限
        c = CacheManager(tmp_path, expire_hours=24, max_size_mb=0.0001)
        # 写入 5 个缓存
        for i in range(5):
            c.set(f"p{i}", "s", "a", f"q{i}", "x" * 100, [])
        files_before = len(list(tmp_path.glob("*.json")))
        assert files_before == 5
        # 触发清理
        c.check_and_clean_if_needed()
        files_after = len(list(tmp_path.glob("*.json")))
        # 应清理至少 1 个（最旧的 20% = max(1, 5//5)=1）
        assert files_after < files_before
        assert files_after >= 1
