import json
from datetime import date, timedelta
from decimal import Decimal

import pytest

from ccma.domain.contributions import claim_total
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError


def _overdue_claim_repository(tmp_path):
    repository = MemberRepository(tmp_path)
    repository.initialize()
    member = repository.create_member(first_name="Reminder", last_name="Test", birth_date="1990-01-01")
    repository.save_contributions(
        member.member_id,
        ContributionData(
            claims=[
                {
                    "claim_id": "claim-1",
                    "claim_key": "overdue-test",
                    "title": "Offene Forderung",
                    "amount": "100.00",
                    "due_date": "2026-01-31",
                    "status": "open",
                }
            ]
        ),
    )
    return repository, member


def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    housekeeper = Housekeeper(repository)
    findings = housekeeper.run(today=date(2026, 2, 10))
    reminder_task = next(item for item in findings if item.code == "reminder_due")
    assert "Zahlungserinnerung" in reminder_task.title

    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[],
    )
    findings = housekeeper.run(today=date(2026, 2, 10))
    reminder_task = next(item for item in findings if item.code == "reminder_due")
    assert "wartet auf Versand" in reminder_task.title

    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    deadline = date.fromisoformat(sent["payment_deadline"])
    assert not any(
        item.code == "reminder_due" for item in housekeeper.run(today=deadline - timedelta(days=1))
    )
    findings = housekeeper.run(today=deadline)
    next_task = next(item for item in findings if item.code == "reminder_due")
    assert "Erste Mahnung" in next_task.title


def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    housekeeper = Housekeeper(repository)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[],
    )
    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    deadline = date.fromisoformat(sent["payment_deadline"])

    findings = [
        item
        for item in housekeeper.run(today=deadline - timedelta(days=1))
        if item.member_id == member.member_id
    ]

    # A reminder was already sent and its own deadline hasn't expired yet -- this is
    # being handled, so it must not appear as an unaddressed "error"-severity overdue
    # claim, only as a low-priority note that the existing deadline is still running.
    assert not any(item.code == "claim_overdue" for item in findings)
    awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
    assert awaiting.severity == "info"
    assert "Zahlungserinnerung" in awaiting.title


def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    housekeeper = Housekeeper(repository)
    repository.set_dunning_hold(
        member.member_id,
        "claim-1",
        active=True,
        reason="Betrag wird geklärt",
    )

    findings = housekeeper.run(today=date(2026, 2, 10))
    assert not any(item.code == "reminder_due" for item in findings)
    # The claim stays visible as a plain overdue notice instead of disappearing entirely
    # while the hold suppresses the dunning escalation.
    assert any(item.code == "claim_overdue" for item in findings)
    with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
        repository.create_reminder_draft(
            member.member_id,
            "claim-1",
            level=1,
            name="Zahlungserinnerung",
            payment_deadline_days=14,
        )
    repository.set_dunning_hold(member.member_id, "claim-1", active=False)
    assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))


def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    housekeeper = Housekeeper(repository)

    # Still within the 7-day default grace period: only the plain overdue notice, no
    # dunning escalation yet.
    within_grace = housekeeper.run(today=date(2026, 2, 4))
    codes = {item.code for item in within_grace if item.member_id == member.member_id}
    assert codes == {"claim_overdue"}

    # Past the grace period: the dunning escalation takes over as the single finding,
    # the redundant plain overdue notice disappears.
    past_grace = housekeeper.run(today=date(2026, 2, 10))
    codes = {item.code for item in past_grace if item.member_id == member.member_id}
    assert codes == {"reminder_due"}


def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    member.iban = "DE89370400440532013000"
    member.mandate_reference = "MANDATE-1"
    member.mandate_signed_at = "2026-01-01"
    member.mandate_active = True
    repository.save_member(member)
    housekeeper = Housekeeper(repository)

    findings = housekeeper.run(today=date(2026, 2, 10))
    codes = {item.code for item in findings if item.member_id == member.member_id}

    assert codes == {"sepa_debit_overdue"}
    sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
    assert "Lastschrift" in sepa_finding.title
    assert "SEPA" in sepa_finding.detail


def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    member.iban = "DE89370400440532013000"
    member.mandate_reference = "MANDATE-1"
    member.mandate_signed_at = "2026-01-01"
    member.mandate_active = True
    repository.save_member(member)
    housekeeper = Housekeeper(repository)

    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Rücklastschrift",
        payment_deadline_days=14,
        items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
    )
    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    deadline = date.fromisoformat(sent["payment_deadline"])

    findings = [
        item
        for item in housekeeper.run(today=deadline - timedelta(days=1))
        if item.member_id == member.member_id
    ]

    assert not any(item.code == "sepa_debit_overdue" for item in findings)
    awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
    assert awaiting.severity == "info"
    assert "Rücklastschrift" in awaiting.title

    # Once the reminder's own deadline has passed without resolution, the plain SEPA
    # follow-up notice returns so it doesn't just silently go quiet forever.
    findings = [
        item for item in housekeeper.run(today=deadline + timedelta(days=1))
        if item.member_id == member.member_id
    ]
    assert any(item.code == "sepa_debit_overdue" for item in findings)
    assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings)


def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
    )
    repository.cancel_reminder(member.member_id, "claim-1", draft["reminder_id"])
    data = repository.get_contributions(member.member_id)
    assert data.reminders[0]["status"] == "cancelled"

    second = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
    )
    repository.mark_reminder_sent(member.member_id, "claim-1", second["reminder_id"])
    with pytest.raises(RepositoryError, match="bereits versandte"):
        repository.cancel_reminder(member.member_id, "claim-1", second["reminder_id"])


def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)

    with pytest.raises(RepositoryError, match="Mahnstufe 1 wurde noch nicht versandt"):
        repository.create_reminder_draft(
            member.member_id,
            "claim-1",
            level=2,
            name="Erste Mahnung",
            payment_deadline_days=14,
            items=[{"description": "Mahngebühr", "amount": "5.00"}],
        )


def test_payment_resolves_open_reminder_task(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    housekeeper = Housekeeper(repository)
    assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
    repository.record_payment(
        member.member_id,
        "claim-1",
        payment_date="2026-02-10",
        amount="100.00",
        allocation_amount="100.00",
    )

    findings = housekeeper.run(today=date(2026, 2, 10))
    assert not any(item.code == "reminder_due" for item in findings)


def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)

    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Rücklastschrift",
        payment_deadline_days=14,
        items=[
            {"description": "Rücklastschriftgebühr", "amount": "5.00"},
            {"description": "Bankgebühr", "amount": "3.50"},
        ],
    )
    assert draft["fee_item_ids"] == []

    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    assert len(sent["fee_item_ids"]) == 2

    data, claim = repository.get_claim(member.member_id, "claim-1")
    descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
    assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
    assert claim_total(claim) == Decimal("108.50")


def test_reminder_items_require_a_description(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)

    with pytest.raises(RepositoryError, match="Beschreibung"):
        repository.create_reminder_draft(
            member.member_id,
            "claim-1",
            level=1,
            name="Rücklastschrift",
            payment_deadline_days=14,
            items=[{"description": "  ", "amount": "5.00"}],
        )


def test_reminder_with_zero_amount_items_creates_no_claim_item(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)

    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[{"description": "Mahngebühr", "amount": "0.00"}],
    )
    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    assert sent["fee_item_ids"] == []
    _data, claim = repository.get_claim(member.member_id, "claim-1")
    assert claim_total(claim) == Decimal("100.00")


def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
    repository = MemberRepository(tmp_path)
    repository.initialize()

    repository.save_reminder_policy(
        grace_days_after_due=10,
        levels=[
            {"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
            {"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
        ],
        standard_fee_items=[
            {"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
        ],
    )

    policy = repository.get_reminder_policy()
    assert policy["grace_days_after_due"] == 10
    assert [level["level"] for level in policy["levels"]] == [1, 2]
    assert policy["levels"][1]["name"] == "Mahnung"
    assert policy["levels"][1]["fee"] == "7.50"
    assert policy["standard_fee_items"] == [
        {"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
    ]


def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
    repository = MemberRepository(tmp_path)
    repository.initialize()

    with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
        repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])

    with pytest.raises(RepositoryError, match="benötigt einen Namen"):
        repository.save_reminder_policy(
            grace_days_after_due=7,
            levels=[{"name": "  ", "fee": "0.00", "payment_deadline_days": 14}],
            standard_fee_items=[],
        )

    with pytest.raises(RepositoryError, match="nicht negativ"):
        repository.save_reminder_policy(
            grace_days_after_due=7,
            levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
            standard_fee_items=[],
        )


def test_edit_reminder_draft_updates_fields_but_not_level(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[],
    )

    edited = repository.edit_reminder_draft(
        member.member_id,
        "claim-1",
        draft["reminder_id"],
        name="Erste Mahnung",
        payment_deadline_days=10,
        detail="Bitte um Ausgleich.",
        items=[{"description": "Mahngebühr", "amount": "5.00"}],
    )
    assert edited["level"] == 1
    assert edited["name"] == "Erste Mahnung"
    assert edited["payment_deadline_days"] == 10
    assert edited["detail"] == "Bitte um Ausgleich."
    assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]


def test_edit_reminder_draft_rejects_already_sent_reminder(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[],
    )
    repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])

    with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
        repository.edit_reminder_draft(
            member.member_id,
            "claim-1",
            draft["reminder_id"],
            name="Andere Bezeichnung",
            payment_deadline_days=14,
        )


def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Rücklastschrift",
        payment_deadline_days=14,
        items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
    )
    sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    assert len(sent["fee_item_ids"]) == 1
    _data, claim = repository.get_claim(member.member_id, "claim-1")
    assert claim_total(claim) == Decimal("105.00")

    reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    assert reverted["status"] == "draft"
    assert reverted["sent_at"] is None
    assert reverted["payment_deadline"] is None
    assert reverted["fee_item_ids"] == []

    _data, claim = repository.get_claim(member.member_id, "claim-1")
    assert claim_total(claim) == Decimal("100.00")
    descriptions = {item["description"] for item in claim["items"]}
    assert "Rücklastschriftgebühr" not in descriptions

    # Now editable and re-sendable, e.g. after fixing the wording.
    repository.edit_reminder_draft(
        member.member_id,
        "claim-1",
        draft["reminder_id"],
        name="Rücklastschrift",
        payment_deadline_days=14,
        items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
    )
    repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    _data, claim = repository.get_claim(member.member_id, "claim-1")
    assert claim_total(claim) == Decimal("107.50")


def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    first = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Zahlungserinnerung",
        payment_deadline_days=14,
        items=[],
    )
    repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
    repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=2,
        name="Erste Mahnung",
        payment_deadline_days=14,
        items=[{"description": "Mahngebühr", "amount": "5.00"}],
    )

    with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
        repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])


def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
    repository, member = _overdue_claim_repository(tmp_path)
    draft = repository.create_reminder_draft(
        member.member_id,
        "claim-1",
        level=1,
        name="Rücklastschrift",
        payment_deadline_days=14,
        items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
    )
    repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
    repository.record_payment(
        member.member_id,
        "claim-1",
        payment_date="2026-02-10",
        amount="105.00",
        allocation_amount="105.00",
    )

    with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
        repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])


def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
    repository = MemberRepository(tmp_path)
    repository.initialize()
    config_path = tmp_path / "repository.json"
    raw = json.loads(config_path.read_text(encoding="utf-8"))
    # Simulate a repository created before "standard_fee_items" existed at all.
    del raw["reminder_policy"]["standard_fee_items"]
    config_path.write_text(json.dumps(raw), encoding="utf-8")

    policy = repository.get_reminder_policy()
    assert policy["standard_fee_items"], "should fall back to built-in defaults"
    assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])

    # But once the board explicitly saves an empty list, that choice is respected.
    repository.save_reminder_policy(
        grace_days_after_due=policy["grace_days_after_due"],
        levels=policy["levels"],
        standard_fee_items=[],
    )
    assert repository.get_reminder_policy()["standard_fee_items"] == []
