"""
覆盖 app/application/history.py 的整理历史查重闸。

监控分发（app/monitor/dispatcher.py）与整理链计划整理段（app/chain/transfer.py）
共用这套判定，本文件只测判定本身的真值表与查询辅助函数，不涉及调用方。
"""
from types import SimpleNamespace

import pytest

from app.runtime.config import settings
from app.application import history as transfer_history_helper
from app.application.history import (
    HistoryGateAction,
    clear_transfer_failures,
    coerce_size,
    describe_history_gate,
    evaluate_history_gate,
    failed_retry_count,
    history_src_size,
    is_skip_action,
    max_failed_retries,
    record_transfer_failure,
    resolve_history,
)


def make_history(status: bool, size=1024, has_src_fileitem: bool = True,
                  history_id: int = 1, src_fileitem_override=None,
                  src=None, src_storage=None, modify_time=None, fileid=None):
    """构造用于查重闸判定的整理记录替身。"""
    if src_fileitem_override is not None:
        src_fileitem = src_fileitem_override
    elif has_src_fileitem:
        src_fileitem = {
            "size": size,
            "modify_time": modify_time,
            "fileid": fileid,
        }
    else:
        src_fileitem = None
    return SimpleNamespace(id=history_id, status=status, src_fileitem=src_fileitem,
                            src=src, src_storage=src_storage)


def _reset_failed_retries(src_path, storage=None):
    """清空失败重试计数，隔离用例之间共享的模块级计数缓存。"""
    clear_transfer_failures(src_path, storage)


# ---------------------------------------------------------------------------
# evaluate_history_gate 真值表：无记录 / 成功记录
# ---------------------------------------------------------------------------


def test_evaluate_history_gate_passes_when_no_record():
    """没有整理记录时应放行整理。"""
    action = evaluate_history_gate(None, file_size=1024)

    assert action == HistoryGateAction.PASS_NO_RECORD


def test_evaluate_history_gate_passes_when_success_size_changed():
    """成功记录但源文件大小已变化时应放行，交由 overwrite_mode 决断。"""
    history = make_history(status=True, size=1024)

    action = evaluate_history_gate(history, file_size=2048)

    assert action == HistoryGateAction.PASS_SIZE_CHANGED


def test_evaluate_history_gate_skips_when_success_size_unchanged():
    """成功记录且源文件大小未变化时应跳过。"""
    history = make_history(status=True, size=1024)

    action = evaluate_history_gate(history, file_size=1024)

    assert action == HistoryGateAction.SKIP


def test_evaluate_history_gate_skips_when_recorded_size_missing():
    """成功记录缺少大小信息（如蓝光目录）时无法比对，保守跳过。"""
    history = make_history(status=True, has_src_fileitem=False)

    action = evaluate_history_gate(history, file_size=1024)

    assert action == HistoryGateAction.SKIP


def test_evaluate_history_gate_skips_when_current_size_is_none():
    """当前文件大小取不到时无法比对，保守跳过。"""
    history = make_history(status=True, size=1024)

    action = evaluate_history_gate(history, file_size=None)

    assert action == HistoryGateAction.SKIP


def test_evaluate_history_gate_skips_when_src_fileitem_is_not_dict():
    """src_fileitem 历史数据异常（不是字典）时无法比对，保守跳过。"""
    history = make_history(status=True, src_fileitem_override="not-a-dict")

    action = evaluate_history_gate(history, file_size=1024)

    assert action == HistoryGateAction.SKIP


def test_evaluate_history_gate_size_changed_ignores_failed_retry_budget(monkeypatch):
    """
    重试次数上限只影响失败记录的判定：即便同路径的失败计数早已超过上限，
    成功记录 + 源文件大小变化时仍应放行，交由 overwrite_mode 决断。
    """
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
    src_path = "/downloads/gate-test-size-changed-ignores-budget.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        for _ in range(5):
            record_transfer_failure(src_path, "local")
        assert failed_retry_count(src_path, "local") > max_failed_retries()

        history = make_history(status=True, size=1024, src=src_path, src_storage="local")
        action = evaluate_history_gate(history, file_size=2048)

        assert action == HistoryGateAction.PASS_SIZE_CHANGED
    finally:
        _reset_failed_retries(src_path, "local")


# ---------------------------------------------------------------------------
# evaluate_history_gate 真值表：失败记录 —— 有界重试
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("retry_count, expected", [
    (0, HistoryGateAction.PASS_FAILED),
    (1, HistoryGateAction.PASS_FAILED),
    (2, HistoryGateAction.PASS_FAILED),
    (3, HistoryGateAction.SKIP_RETRY_EXHAUSTED),
    (4, HistoryGateAction.SKIP_RETRY_EXHAUSTED),
])
def test_evaluate_history_gate_failed_record_retry_budget_truth_table(monkeypatch, retry_count, expected):
    """失败记录按有界重试判定：未达上限（含 0）放行重试，达到或超过上限后跳过。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 3)
    history = make_history(status=False, size=1024)

    action = evaluate_history_gate(history, file_size=1024, retry_count=retry_count)

    assert action == expected


def test_evaluate_history_gate_failed_record_queries_realtime_count_when_omitted(monkeypatch):
    """retry_count 省略（为 None）时应按 history.src / history.src_storage 实时查询失败计数。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
    src_path = "/downloads/gate-test-realtime-count.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        history = make_history(status=False, size=1024, src=src_path, src_storage="local")

        # 计数为 0：未达上限（2），放行重试
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.PASS_FAILED

        # 累计一次失败，计数为 1：仍未达上限
        record_transfer_failure(src_path, "local")
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.PASS_FAILED

        # 累计第二次失败，计数为 2：达到上限，跳过
        record_transfer_failure(src_path, "local")
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
    finally:
        _reset_failed_retries(src_path, "local")


def test_evaluate_history_gate_explicit_retry_count_overrides_realtime_lookup(monkeypatch):
    """显式传入 retry_count 时不应再触发实时查询，不受计数器实际状态影响。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 3)
    src_path = "/downloads/gate-test-explicit-overrides-realtime.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        for _ in range(10):
            record_transfer_failure(src_path, "local")
        history = make_history(status=False, size=1024, src=src_path, src_storage="local")

        # 即使实时计数早已超限，显式传入的低 retry_count 仍应放行
        action = evaluate_history_gate(history, file_size=1024, retry_count=0)

        assert action == HistoryGateAction.PASS_FAILED
    finally:
        _reset_failed_retries(src_path, "local")


def test_failed_history_new_size_passes_and_resets_retry_budget(monkeypatch):
    """失败预算耗尽后同路径文件大小变化时，应放行新版本并从第 1 次失败重新计数。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
    src_path = "/downloads/gate-test-failed-new-size.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        history = make_history(
            status=False,
            size=1024,
            src=src_path,
            src_storage="local",
        )
        record_transfer_failure(src_path, "local", file_size=1024)
        record_transfer_failure(src_path, "local", file_size=1024)
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.SKIP_RETRY_EXHAUSTED

        assert (
            evaluate_history_gate(history, file_size=2048)
            == HistoryGateAction.PASS_FAILED_VERSION_CHANGED
        )
        assert record_transfer_failure(src_path, "local", file_size=2048) == 1
        assert failed_retry_count(src_path, "local", file_size=2048) == 1
    finally:
        _reset_failed_retries(src_path, "local")


@pytest.mark.parametrize(
    "current_fields",
    [
        {"file_modify_time": 200.0, "fileid": "same-id"},
        {"file_modify_time": 100.0, "fileid": "new-id"},
    ],
)
def test_failed_history_same_size_new_fingerprint_passes(current_fields, monkeypatch):
    """大小相同但修改时间或文件 ID 改变时，也应视为失败文件的新版本。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1)
    src_path = "/downloads/gate-test-failed-new-fingerprint.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        history = make_history(
            status=False,
            size=1024,
            modify_time=100.0,
            fileid="same-id",
            src=src_path,
            src_storage="local",
        )
        record_transfer_failure(
            src_path,
            "local",
            file_size=1024,
            file_modify_time=100.0,
            fileid="same-id",
        )
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.SKIP_RETRY_EXHAUSTED

        assert (
            evaluate_history_gate(history, file_size=1024, **current_fields)
            == HistoryGateAction.PASS_FAILED_VERSION_CHANGED
        )
    finally:
        _reset_failed_retries(src_path, "local")


def test_legacy_integer_retry_count_is_upgraded_after_new_version_failure(monkeypatch):
    """Redis 中遗留的整数计数不得阻断新版本，并应在下次失败时升级为指纹状态。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 2)
    src_path = "/downloads/gate-test-legacy-retry-state.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        key = transfer_history_helper.failed_retry_key(src_path, "local")
        transfer_history_helper._failed_retry_counts[key] = 2
        history = make_history(
            status=False,
            size=1024,
            src=src_path,
            src_storage="local",
        )
        assert evaluate_history_gate(history, file_size=1024) == HistoryGateAction.SKIP_RETRY_EXHAUSTED
        assert (
            evaluate_history_gate(history, file_size=2048)
            == HistoryGateAction.PASS_FAILED_VERSION_CHANGED
        )

        assert record_transfer_failure(src_path, "local", file_size=2048) == 1
        assert failed_retry_count(src_path, "local", file_size=2048) == 1
    finally:
        _reset_failed_retries(src_path, "local")


# ---------------------------------------------------------------------------
# max_failed_retries 钳制
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("raw, expected", [
    (-1, 1),
    (0, 1),
    (1, 1),
    (3, 3),
    (10, 10),
    (11, 10),
])
def test_max_failed_retries_clamps_numeric_values(monkeypatch, raw, expected):
    """合法区间外的配置值应被钳制到 [1, 10]，区间内的值原样返回。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", raw)

    assert max_failed_retries() == expected


def test_max_failed_retries_falls_back_when_non_integer(monkeypatch):
    """非整数配置（如解析失败的字符串）应回退为下界 1。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", "abc")

    assert max_failed_retries() == 1


# ---------------------------------------------------------------------------
# 失败计数器：record_transfer_failure / failed_retry_count / clear_transfer_failures
# ---------------------------------------------------------------------------


def test_record_transfer_failure_returns_incrementing_count():
    """连续记录失败应返回递增的累计次数。"""
    src_path = "/downloads/gate-test-incrementing-count.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        assert record_transfer_failure(src_path, "local") == 1
        assert record_transfer_failure(src_path, "local") == 2
        assert record_transfer_failure(src_path, "local") == 3
        assert failed_retry_count(src_path, "local") == 3
    finally:
        _reset_failed_retries(src_path, "local")


def test_clear_transfer_failures_resets_count_to_zero():
    """清空后应归零，且不再影响后续查询。"""
    src_path = "/downloads/gate-test-clear-resets.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        record_transfer_failure(src_path, "local")
        record_transfer_failure(src_path, "local")
        assert failed_retry_count(src_path, "local") == 2

        clear_transfer_failures(src_path, "local")

        assert failed_retry_count(src_path, "local") == 0
    finally:
        _reset_failed_retries(src_path, "local")


def test_failed_retry_count_isolates_different_storages_for_same_path():
    """相同源路径但不同 storage 的失败计数应互不影响。"""
    src_path = "/downloads/gate-test-storage-isolation.mkv"
    _reset_failed_retries(src_path, "local")
    _reset_failed_retries(src_path, "alist")
    try:
        record_transfer_failure(src_path, "local")
        record_transfer_failure(src_path, "local")
        record_transfer_failure(src_path, "alist")

        assert failed_retry_count(src_path, "local") == 2
        assert failed_retry_count(src_path, "alist") == 1
    finally:
        _reset_failed_retries(src_path, "local")
        _reset_failed_retries(src_path, "alist")


def test_failed_retry_count_defaults_to_zero_without_record():
    """未记录过失败的路径应返回 0。"""
    src_path = "/downloads/gate-test-no-record-yet.mkv"
    _reset_failed_retries(src_path, "local")

    assert failed_retry_count(src_path, "local") == 0


# ---------------------------------------------------------------------------
# is_skip_action
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("action, expected", [
    (HistoryGateAction.PASS_NO_RECORD, False),
    (HistoryGateAction.PASS_FAILED, False),
    (HistoryGateAction.PASS_FAILED_VERSION_CHANGED, False),
    (HistoryGateAction.PASS_SIZE_CHANGED, False),
    (HistoryGateAction.SKIP_RETRY_EXHAUSTED, True),
    (HistoryGateAction.SKIP, True),
])
def test_is_skip_action(action, expected):
    """跳过整理的判定应只对 SKIP 与 SKIP_RETRY_EXHAUSTED 返回 True。"""
    assert is_skip_action(action) is expected


# ---------------------------------------------------------------------------
# coerce_size
# ---------------------------------------------------------------------------


def test_coerce_size_returns_none_for_none():
    """None 应原样返回 None，表示不可比对。"""
    assert coerce_size(None) is None


def test_coerce_size_returns_none_for_non_numeric_string():
    """非数字字符串无法转换，应返回 None。"""
    assert coerce_size("not-a-number") is None


def test_coerce_size_truncates_float():
    """浮点数应按 int() 截断转换。"""
    assert coerce_size(1024.9) == 1024


def test_coerce_size_parses_numeric_string():
    """数字字符串应正确转换为整数。"""
    assert coerce_size("2048") == 2048


# ---------------------------------------------------------------------------
# resolve_history
# ---------------------------------------------------------------------------


def test_resolve_history_upgrades_failed_hit_to_success_record():
    """get_by_src 命中失败记录且存在成功记录时，应返回成功记录。"""
    failed_history = make_history(status=False, history_id=1)
    success_history = make_history(status=True, history_id=2)
    oper = SimpleNamespace(
        get_by_src=lambda src, storage=None: failed_history,
        get_success_by_src=lambda src, storage=None: success_history,
    )

    history = resolve_history("/downloads/a.mkv", storage="local", transfer_history_oper=oper)

    assert history is success_history


def test_resolve_history_keeps_failed_hit_when_no_success_record():
    """get_by_src 命中失败记录但不存在成功记录时，应返回原失败记录。"""
    failed_history = make_history(status=False, history_id=1)
    oper = SimpleNamespace(
        get_by_src=lambda src, storage=None: failed_history,
        get_success_by_src=lambda src, storage=None: None,
    )

    history = resolve_history("/downloads/a.mkv", storage="local", transfer_history_oper=oper)

    assert history is failed_history


def test_resolve_history_does_not_query_success_when_already_successful():
    """get_by_src 直接命中成功记录时，不应再查询 get_success_by_src。"""
    success_history = make_history(status=True, history_id=3)
    success_query_calls = []

    def get_success_by_src(src, storage=None):
        success_query_calls.append(src)
        return success_history

    oper = SimpleNamespace(
        get_by_src=lambda src, storage=None: success_history,
        get_success_by_src=get_success_by_src,
    )

    history = resolve_history("/downloads/a.mkv", storage="local", transfer_history_oper=oper)

    assert history is success_history
    assert success_query_calls == []


def test_resolve_history_returns_none_when_no_record():
    """没有命中任何记录时应返回 None，不触发额外查询。"""
    success_query_calls = []

    def get_success_by_src(src, storage=None):
        success_query_calls.append(src)
        return None

    oper = SimpleNamespace(
        get_by_src=lambda src, storage=None: None,
        get_success_by_src=get_success_by_src,
    )

    history = resolve_history("/downloads/a.mkv", storage="local", transfer_history_oper=oper)

    assert history is None
    assert success_query_calls == []


# ---------------------------------------------------------------------------
# describe_history_gate
# ---------------------------------------------------------------------------


def test_describe_history_gate_reports_no_record():
    """没有记录时应给出明确的无记录说明。"""
    assert describe_history_gate(None, file_size=1024) == "无整理记录"


def test_describe_history_gate_includes_status_and_sizes():
    """说明文案应包含记录状态、记录中的大小与当前大小两个数值。"""
    history = make_history(status=True, size=1024, history_id=9)

    description = describe_history_gate(history, file_size=2048)

    assert "成功记录 #9" in description
    assert str(history_src_size(history)) in description
    assert str(coerce_size(2048)) in description
    assert "1024" in description
    assert "2048" in description


def test_describe_history_gate_reports_failed_status_with_retry_progress(monkeypatch):
    """失败记录的说明文案应标注记录号，并包含「已重试 n/max 次」的实时计数。"""
    monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 3)
    src_path = "/downloads/gate-test-describe-failed.mkv"
    _reset_failed_retries(src_path, "local")
    try:
        record_transfer_failure(src_path, "local")
        record_transfer_failure(src_path, "local")
        history = make_history(status=False, size=1024, history_id=5,
                                src=src_path, src_storage="local")

        description = describe_history_gate(history, file_size=1024)

        assert "失败记录 #5" in description
        assert "已重试" in description
        assert "2/3" in description
    finally:
        _reset_failed_retries(src_path, "local")


def test_describe_history_gate_reports_incomparable_sizes():
    """两侧大小都取不到时应说明大小不可比对。"""
    history = make_history(status=True, has_src_fileitem=False, history_id=7)

    description = describe_history_gate(history, file_size=None)

    assert description == "成功记录 #7，大小不可比对"


def test_clear_transfer_failures_is_safe_when_no_count_recorded():
    """
    清空从未失败过的源路径不得抛异常。

    整理成功回调会对每个文件无条件清零，而绝大多数文件从未失败过；
    底层 CacheBackend.pop 把「default 为 None」当成「未提供 default」，
    键不存在时会抛 KeyError，一旦回归就会让每一次首次成功整理都失败。
    """
    clear_transfer_failures("/downloads/gate-test-never-failed.mkv", "local")

    assert failed_retry_count("/downloads/gate-test-never-failed.mkv", "local") == 0


def test_clear_transfer_failures_is_safe_for_empty_path():
    """源路径为空时清零应静默返回，不得抛异常。"""
    clear_transfer_failures(None, None)
    clear_transfer_failures("", "local")
