from datetime import date, timedelta

from ccma.domain.contributions import claim_balance, claim_status, money_text
from ccma.rules.api import RuleAction, RuleContext, task

RULE_ID = "reminder-due"
ORDER = 60

DEFAULT_POLICY = {
    "grace_days_after_due": 7,
    "levels": [
        {"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14},
        {"level": 2, "name": "Erste Mahnung", "fee": "5.00", "payment_deadline_days": 14},
        {"level": 3, "name": "Zweite Mahnung", "fee": "5.00", "payment_deadline_days": 14},
    ],
}


def evaluate(context: RuleContext) -> list[RuleAction]:
    """Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
    grace period, then either the postal dunning escalation or -- for members paying by
    SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
    "check the direct debit" notice. Falls back to the plain overdue notice whenever no
    escalation applies (dunning hold active, or between reminder levels), so there is
    always exactly one finding for an overdue claim, never both an overdue notice and a
    reminder notice at once."""
    policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
    levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
    grace_days = int(policy.get("grace_days_after_due", 7))
    actions: list[RuleAction] = []
    for claim in context.contributions.claims:
        claim_id = str(claim.get("claim_id", ""))
        if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
            "open",
            "partially_paid",
            "overdue",
        }:
            continue
        if claim_balance(context.contributions, claim) <= 0:
            continue
        try:
            due = date.fromisoformat(str(claim.get("due_date", "")))
        except ValueError:
            continue
        days_overdue = (context.today - due).days
        if days_overdue < 0:
            continue  # not yet due; claim-status handles the "due soon" notice

        escalation = None
        if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
            if context.member.mandate_active:
                escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
            else:
                escalation = _reminder_action(context, claim, claim_id, levels, policy)
        actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
    return actions


def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction:
    title = str(claim.get("title") or "Beitragsforderung")
    claim_type = str(claim.get("type", ""))
    claim_key = str(claim.get("claim_key") or claim_id)
    return task(
        rule_id=RULE_ID,
        member=context.member,
        key_suffix=f"overdue:{claim_key}",
        severity="error",
        title=f"{context.member.display_name}: {title} überfällig",
        detail=f"Fälligkeit war vor {days_overdue} Tagen.",
        due_date=due,
        code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
    )


def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
    pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
    if pending:
        return _sepa_awaiting_action(context, claim, claim_id, *pending)
    return _sepa_action(context, claim, claim_id, days_overdue)


def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
    balance = money_text(claim_balance(context.contributions, claim))
    return task(
        rule_id=RULE_ID,
        member=context.member,
        key_suffix=f"{claim_id}:sepa",
        severity="warning",
        code="sepa_debit_overdue",
        title=f"{context.member.display_name}: Lastschrift überfällig – Einzug prüfen",
        detail=(
            f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit "
            f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine "
            "postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen "
            "bzw. erneut anstoßen."
        ),
    )


def _sepa_awaiting_action(
    context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
) -> RuleAction:
    """A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
    claim and its own deadline hasn't expired yet -- staff already engaged, so this
    must not keep nagging "check the direct debit" as if nothing had happened."""
    name = str(reminder.get("name") or "Rücklastschrift")
    balance = money_text(claim_balance(context.contributions, claim))
    return task(
        rule_id=RULE_ID,
        member=context.member,
        key_suffix=f"{claim_id}:sepa-awaiting",
        severity="info",
        code="sepa_debit_awaiting_deadline",
        title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
        detail=(
            f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
            f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
        ),
        due_date=trigger_date,
    )


def _latest_pending_reminder(
    reminders: list[dict], claim_id: str, today: date
) -> tuple[dict, date] | None:
    best: tuple[dict, date] | None = None
    for item in reminders:
        if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
            continue
        try:
            deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
        except ValueError:
            continue
        if today > deadline:
            continue
        if best is None or deadline > best[1]:
            best = (item, deadline)
    return best


def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
    reminders = [
        item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
    ]
    sent_levels = {
        int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
    }
    next_level = next(
        (definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
        None,
    )
    if not next_level:
        return None
    level = int(next_level.get("level", 0))
    trigger_date = _trigger_date(claim, sent_levels, level, policy)
    if not trigger_date:
        return None
    if context.today < trigger_date:
        # A reminder for the previous level was already sent and its own payment
        # deadline hasn't expired yet -- this is being handled, not neglected, so it
        # must not show up as the same "error" severity as a plain unaddressed overdue
        # claim (that fallback is what the caller uses when this returns None).
        return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
    draft_exists = any(
        int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
        for item in reminders
    )
    name = str(next_level.get("name") or f"Mahnung Stufe {level}")
    balance = money_text(claim_balance(context.contributions, claim))
    title = (
        f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
        if draft_exists
        else f"{context.member.display_name}: {name} fällig"
    )
    detail = (
        f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
        f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
    )
    return task(
        rule_id=RULE_ID,
        member=context.member,
        key_suffix=f"{claim_id}:level-{level}",
        severity="warning",
        code="reminder_due",
        title=title,
        detail=detail,
        due_date=trigger_date,
    )


def _awaiting_deadline_action(
    context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
) -> RuleAction:
    previous_level = level - 1
    previous = sent_levels.get(previous_level) or {}
    name = str(previous.get("name") or f"Mahnstufe {previous_level}")
    balance = money_text(claim_balance(context.contributions, claim))
    return task(
        rule_id=RULE_ID,
        member=context.member,
        key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
        severity="info",
        code="reminder_awaiting_deadline",
        title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
        detail=(
            f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
            f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
        ),
        due_date=trigger_date,
    )


def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
    if level == 1:
        try:
            due_date = date.fromisoformat(str(claim.get("due_date", "")))
        except ValueError:
            return None
        return due_date + timedelta(days=int(policy.get("grace_days_after_due", 7)))
    previous = sent_levels.get(level - 1)
    if not previous:
        return None
    try:
        return date.fromisoformat(str(previous.get("payment_deadline", "")))
    except ValueError:
        return None


def _hold_is_active(claim, today: date) -> bool:
    hold = claim.get("dunning_hold") or {}
    if not hold.get("active"):
        return False
    until = hold.get("until")
    if not until:
        return True
    try:
        return date.fromisoformat(str(until)) >= today
    except ValueError:
        return True
