"""发票与支付记录匹配模块单元测试

覆盖范围：
- 辅助函数：_safe_float, _relative_tolerance, _build_invoice_summary
- 一对一匹配：精确匹配、容差内匹配、容差外不匹配
- 一对多匹配：精确匹配阶段、贪心匹配阶段、回滚逻辑
- 记录构建：_build_payment_records, _invoices_to_records
- 端到端：match_invoices_to_cards（直接传入分类好的支付记录）
"""

from __future__ import annotations

from typing import Any

from src.doc.matcher import (
    _build_invoice_summary,
    _build_payment_records,
    _invoices_to_records,
    _match,
    _match_one_to_many,
    _match_one_to_one,
    _relative_tolerance,
    _safe_float,
    match_invoices_to_cards,
)

# 字段键名（与源码中的字符串字面量保持一致）
K_INVOICE_TYPE = "invoice_type"
K_INVOICE_NUMBER = "invoice_number"
K_TOTAL_AMOUNT = "total_amount"
K_ITEM_NAME = "item_name"
K_PERSON_NAME = "person_name"
K_CARD_DATE = "card_date"
K_CARD_NO = "card_no"
K_CARD_AMOUNT = "card_amount"
K_MATCHED_INVOICES = "_matched_invoices"
K_RELATIVE_INVOICE_COUNT = "relative_invoice_count"
K_INVOICE_DETAIL = "invoice_detail"
K_REMARK = "remark"
K_SOURCE_FILE = "source_file"

INVOICE_TYPE_TRAIN = "train"
INVOICE_TYPE_HOTEL = "hotel"
INVOICE_TYPE_GENERAL = "general"

# ------------------------------------------------------------------
# Fixture helpers
# ------------------------------------------------------------------


def _make_invoice(number: str, amount: float, inv_type: str = INVOICE_TYPE_GENERAL) -> dict[str, Any]:
    inv: dict[str, Any] = {
        K_INVOICE_NUMBER: number,
        K_INVOICE_TYPE: inv_type,
        K_TOTAL_AMOUNT: str(amount),
    }
    if inv_type == INVOICE_TYPE_TRAIN:
        inv[K_PERSON_NAME] = f"person{number}"
    elif inv_type == INVOICE_TYPE_GENERAL:
        inv[K_ITEM_NAME] = f"item{number}"
    return inv


def _make_card(date: str, amount: float, card_no: str = "6228480000000000") -> dict[str, Any]:
    return {
        K_CARD_DATE: date,
        K_CARD_NO: card_no,
        K_CARD_AMOUNT: str(amount),
        K_SOURCE_FILE: "card.png",
    }


# ------------------------------------------------------------------
# 辅助函数
# ------------------------------------------------------------------


class TestSafeFloat:
    """安全浮点转换"""

    def test_normal_string(self):
        assert _safe_float("123.45") == 123.45

    def test_with_comma(self):
        assert _safe_float("1,234.56") == 1234.56

    def test_none_returns_default(self):
        assert _safe_float(None) == 0.0

    def test_empty_string_returns_default(self):
        assert _safe_float("") == 0.0

    def test_whitespace_returns_default(self):
        assert _safe_float("   ") == 0.0

    def test_invalid_string_returns_default(self):
        assert _safe_float("abc") == 0.0

    def test_custom_default(self):
        assert _safe_float(None, default=-1.0) == -1.0

    def test_int_input(self):
        assert _safe_float(42) == 42.0


class TestRelativeTolerance:
    """相对容差计算"""

    def test_default_rate(self):
        assert _relative_tolerance(1000) == 30.0

    def test_custom_rate(self):
        assert _relative_tolerance(1000, 0.03) == 30.0

    def test_negative_base(self):
        assert _relative_tolerance(-200, 0.03) == 6.0

    def test_zero_base(self):
        assert _relative_tolerance(0, 0.03) == 0.0


class TestBuildInvoiceSummary:
    """发票汇总字符串"""

    def test_single_invoice(self):
        invoices = [_make_invoice("INV001", 100.0)]
        result = _build_invoice_summary(invoices)
        assert "INV001" in result
        assert "100.0" in result

    def test_multiple_invoices(self):
        invoices = [
            _make_invoice("INV001", 100.0),
            _make_invoice("INV002", 200.0),
        ]
        result = _build_invoice_summary(invoices)
        assert " | " in result
        assert "INV001" in result
        assert "INV002" in result

    def test_train_uses_person_name(self):
        invoices = [_make_invoice("TRAIN001", 500.0, INVOICE_TYPE_TRAIN)]
        result = _build_invoice_summary(invoices)
        assert "personTRAIN001" in result
        assert INVOICE_TYPE_TRAIN in result

    def test_hotel_uses_fixed_label(self):
        invoices = [_make_invoice("HOTEL001", 800.0, INVOICE_TYPE_HOTEL)]
        result = _build_invoice_summary(invoices)
        assert "hotel[hotel]" in result

    def test_general_uses_project_name(self):
        invoices = [_make_invoice("INV001", 100.0, INVOICE_TYPE_GENERAL)]
        result = _build_invoice_summary(invoices)
        assert "itemINV001" in result

    def test_missing_fields_falls_back_to_number(self):
        inv = {K_INVOICE_NUMBER: "INV001", K_TOTAL_AMOUNT: "50.0"}
        result = _build_invoice_summary([inv])
        assert "INV001" in result

    def test_empty_invoices_returns_empty(self):
        result = _build_invoice_summary([])
        assert result == ""


# ------------------------------------------------------------------
# 一对一匹配
# ------------------------------------------------------------------


class TestMatchOneToOne:
    """一对一匹配"""

    def test_exact_match(self):
        invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
        cards = [_make_card("2026-01-01", 500), _make_card("2026-01-02", 300)]
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_one(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        assert 1 in result
        assert result[0] == [0]
        assert result[1] == [1]

    def test_within_tolerance(self):
        invoices = [_make_invoice("A", 500)]
        cards = [_make_card("2026-01-01", 490)]
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_one(invoices, cards, 0.03, assigned, result)

        assert 0 in result

    def test_outside_tolerance_no_match(self):
        invoices = [_make_invoice("A", 500)]
        cards = [_make_card("2026-01-01", 400)]
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_one(invoices, cards, 0.03, assigned, result)

        assert 0 not in result

    def test_sorted_by_amount_desc(self):
        invoices = [_make_invoice("A", 100), _make_invoice("B", 500)]
        cards = [_make_card("2026-01-01", 100), _make_card("2026-01-02", 500)]
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])

        invoices.sort(key=lambda i: i["_amount"], reverse=True)
        cards.sort(key=lambda c: c["_amount"], reverse=True)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_one(invoices, cards, 0.03, assigned, result)

        assert len(result) == 2


# ------------------------------------------------------------------
# 一对多匹配
# ------------------------------------------------------------------


class TestMatchOneToMany:
    """一对多匹配"""

    def _prepare(self, invoices, cards):
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
        invoices.sort(key=lambda i: i["_amount"], reverse=True)
        cards.sort(key=lambda c: c["_amount"], reverse=True)

    def test_exact_match_phase(self):
        invoices = [
            _make_invoice("A", 500),
            _make_invoice("B", 300),
            _make_invoice("C", 200),
        ]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        matched_inv = invoices[result[0][0]]
        assert matched_inv["_amount"] == 500.0

    def test_greedy_match_multiple_invoices(self):
        invoices = [
            _make_invoice("A", 300),
            _make_invoice("B", 200),
            _make_invoice("C", 100),
        ]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        assert len(result[0]) == 2

    def test_greedy_with_remaining_invoice(self):
        invoices = [
            _make_invoice("A", 300),
            _make_invoice("B", 200),
            _make_invoice("C", 100),
        ]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        assert len(assigned) == 2

    def test_rollback_when_over_shooting(self):
        invoices = [
            _make_invoice("A", 600),
            _make_invoice("B", 500),
        ]
        cards = [_make_card("2026-01-01", 1000)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        assert len(result[0]) == 1

    def test_zero_amount_card_skipped(self):
        invoices = [_make_invoice("A", 100)]
        cards = [_make_card("2026-01-01", 0)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 not in result

    def test_zero_amount_invoice_skipped(self):
        invoices = [
            _make_invoice("A", 100),
            _make_invoice("B", 0),
        ]
        cards = [_make_card("2026-01-01", 100)]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        matched_inv = invoices[result[0][0]]
        assert matched_inv["_amount"] == 100.0

    def test_multiple_cards_greedy(self):
        invoices = [
            _make_invoice("A", 300),
            _make_invoice("B", 200),
            _make_invoice("C", 150),
            _make_invoice("D", 100),
        ]
        cards = [
            _make_card("2026-01-01", 500),
            _make_card("2026-01-02", 150),
        ]
        self._prepare(invoices, cards)

        assigned: set[int] = set()
        result: dict[int, list[int]] = {}
        _match_one_to_many(invoices, cards, 0.03, assigned, result)

        assert 0 in result
        assert 1 in result


# ------------------------------------------------------------------
# _match 路由
# ------------------------------------------------------------------


class TestMatchRouter:
    """_match 根据数量选择策略"""

    def _prepare(self, invoices, cards):
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])
        invoices.sort(key=lambda i: i["_amount"], reverse=True)
        cards.sort(key=lambda c: c["_amount"], reverse=True)

    def test_equal_count_routes_to_one_to_one(self):
        invoices = [_make_invoice("A", 500)]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        result = _match(cards, invoices, 0.03)
        assert 0 in result

    def test_more_invoices_routes_to_one_to_many(self):
        invoices = [_make_invoice("A", 300), _make_invoice("B", 200)]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        result = _match(cards, invoices, 0.03)
        assert 0 in result


# ------------------------------------------------------------------
# 记录构建
# ------------------------------------------------------------------


class TestBuildPaymentRecords:
    """构建支付记录列表"""

    def _prepare(self, invoices, cards):
        for inv in invoices:
            inv["_amount"] = _safe_float(inv[K_TOTAL_AMOUNT])
        for card in cards:
            card["_amount"] = _safe_float(card[K_CARD_AMOUNT])

    def test_matched_records_have_card_info(self):
        invoices = [_make_invoice("A", 500)]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        card_to_invoices = {0: [0]}
        records = _build_payment_records(cards, invoices, card_to_invoices)

        assert len(records) == 1
        assert records[0][K_CARD_DATE] == "2026-01-01"
        assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"
        assert "unmatched" not in records[0][K_REMARK]

    def test_unmatched_invoices_become_separate_records(self):
        invoices = [_make_invoice("A", 500), _make_invoice("B", 300)]
        cards = [_make_card("2026-01-01", 500)]
        self._prepare(invoices, cards)

        card_to_invoices = {0: [0]}
        records = _build_payment_records(cards, invoices, card_to_invoices)

        assert len(records) == 2
        unmatched = [r for r in records if r[K_REMARK] == "unmatched"]
        assert len(unmatched) == 1

    def test_empty_mapping_returns_no_records(self):
        invoices = []
        cards = []
        records = _build_payment_records(cards, invoices, {})
        assert records == []


class TestInvoicesToRecords:
    """无刷卡记录时将发票转为独立记录"""

    def test_single_invoice(self):
        invoices = [_make_invoice("A", 100)]
        records = _invoices_to_records(invoices)
        assert len(records) == 1
        assert records[0][K_RELATIVE_INVOICE_COUNT] == "1"

    def test_multiple_invoices(self):
        invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
        records = _invoices_to_records(invoices)
        assert len(records) == 2

    def test_empty_invoices(self):
        records = _invoices_to_records([])
        assert records == []


# ------------------------------------------------------------------
# 端到端集成
# ------------------------------------------------------------------


class TestMatchInvoicesToCards:
    """match_invoices_to_cards 端到端测试"""

    def test_no_cards_returns_invoice_records(self):
        invoices = [_make_invoice("A", 100), _make_invoice("B", 200)]
        result = match_invoices_to_cards(invoices, cards=None)

        assert len(result) == 2
        for rec in result:
            assert rec[K_CARD_DATE] == ""
            assert rec[K_CARD_NO] == ""

    def test_one_to_one_end_to_end(self):
        cards = [
            {
                K_CARD_DATE: "2026-01-01",
                K_CARD_NO: "6228480000000000",
                K_CARD_AMOUNT: "500.00",
            }
        ]
        invoices = [_make_invoice("A", 500)]
        result = match_invoices_to_cards(invoices, cards=cards)

        assert len(result) == 1
        assert result[0][K_CARD_DATE] == "2026-01-01"
        assert result[0][K_RELATIVE_INVOICE_COUNT] == "1"
        assert "_amount" not in result[0]

    def test_one_to_many_end_to_end(self):
        cards = [
            {
                K_CARD_DATE: "2026-01-01",
                K_CARD_NO: "6228480000000000",
                K_CARD_AMOUNT: "500.00",
            }
        ]
        invoices = [
            _make_invoice("A", 300),
            _make_invoice("B", 200),
        ]
        result = match_invoices_to_cards(invoices, cards=cards)

        assert len(result) == 1
        assert result[0][K_RELATIVE_INVOICE_COUNT] == "2"

    def test_unmatched_invoices_included(self):
        cards = [
            {
                K_CARD_DATE: "2026-01-01",
                K_CARD_NO: "6228480000000000",
                K_CARD_AMOUNT: "500.00",
            }
        ]
        invoices = [
            _make_invoice("A", 500),
            _make_invoice("B", 100),
        ]
        result = match_invoices_to_cards(invoices, cards=cards)

        assert len(result) == 2
        unmatched = [r for r in result if r[K_REMARK] == "unmatched"]
        assert len(unmatched) == 1

    def test_internal_fields_cleaned(self):
        cards = [
            {
                K_CARD_DATE: "2026-01-01",
                K_CARD_NO: "6228480000000000",
                K_CARD_AMOUNT: "500.00",
            }
        ]
        invoices = [_make_invoice("A", 500)]
        result = match_invoices_to_cards(invoices, cards=cards)

        for inv in result[0][K_MATCHED_INVOICES]:
            assert "_amount" not in inv

    def test_empty_cards_list(self):
        invoices = [_make_invoice("A", 100)]
        result = match_invoices_to_cards(invoices, cards=[])

        assert len(result) == 1
        assert result[0][K_CARD_DATE] == ""

    def test_multiple_cards(self):
        cards = [
            {K_CARD_DATE: "2026-01-01", K_CARD_NO: "6228480000000001", K_CARD_AMOUNT: "500.00"},
            {K_CARD_DATE: "2026-01-02", K_CARD_NO: "6228480000000002", K_CARD_AMOUNT: "300.00"},
        ]
        invoices = [
            _make_invoice("A", 500),
            _make_invoice("B", 300),
        ]
        result = match_invoices_to_cards(invoices, cards=cards)

        assert len(result) == 2
