from __future__ import annotations

import hashlib
import os
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path

from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import Member
from ccma.services.mail_composition import (
    compose_mail,
    payment_instructions,
    render_template,
    template_values,
)
from ccma.services.mail_delivery import (
    append_message,
    ensure_imap_folder,
    imap_session,
    send_via_smtp,
    smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError

OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}


@dataclass(frozen=True, slots=True)
class GeneratedWelcomeMail:
    # None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
    export_path: Path | None
    archive_path: Path
    sha256: str


def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
    """The claims a welcome mail can bill: everything still owed, which for a fresh
    member is the admission fee plus the first contribution claim(s)."""
    data = repository.get_contributions(member_id)
    selected = [
        claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
    ]
    return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))


def membership_start(member: Member, *, today: date | None = None) -> date:
    """The date the membership (and with it the first contribution) starts. A member
    accepted this month usually starts on the 1st of the next one, and that date
    cannot be stored yet -- "Mitglied seit" rejects future dates."""
    try:
        return date.fromisoformat(str(member.membership_started_at))
    except ValueError:
        pass
    try:
        reference = date.fromisoformat(str(member.accepted_at))
    except ValueError:
        reference = today or date.today()
    return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)


def welcome_mail_bytes(
    repository: MemberRepository,
    *,
    member: Member,
    claims: list[dict],
    balances: dict[str, Decimal],
    organization: dict,
    sender_name: str,
    sender_email: str,
    signature: str,
    created_at: datetime | None = None,
) -> bytes:
    values = template_values(member, organization, signature=signature, created_at=created_at)
    entries = [
        {
            "claim.title": str(claim.get("title", "")),
            "claim.description": str(claim.get("description", "") or claim.get("title", "")),
            "claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
            "claim.amount": money_text(claim_total(claim)),
            "claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
        }
        for claim in claims
    ]
    total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
    due_dates = sorted(
        str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
    )
    first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
    reference = " – ".join(
        part
        for part in (
            f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
            "Mitgliedsbeitrag",
        )
        if part
    )
    values.update(
        {
            "membership.start_date": format_date_for_display(
                membership_start(member).isoformat()
            ),
            "claims.list": "\n".join(
                f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
                f"{entry['claim.balance']} Euro"
                for entry in entries
            ),
            "claims.total": money_text(total),
            "claims.count": str(len(entries)),
            "claims.first_due_date": first_due_date,
            "payment.reference": reference,
            "payment.instructions": payment_instructions(
                member, organization, due_date=first_due_date, reference=reference
            ),
        }
    )
    rendered = render_template(repository, "welcome", values, {"claims": entries})
    return compose_mail(
        recipient=member.email,
        subject=rendered.subject,
        body=rendered.body,
        sender_name=sender_name,
        sender_email=sender_email,
        created_at=created_at,
    )


def generate_and_send_welcome_mail(
    repository: MemberRepository,
    member_id: str,
    *,
    claim_ids: list[str] | None = None,
    delivery_mode: str,
    output_path: Path | str | None = None,
    sender_name: str,
    sender_email: str,
    signature: str,
) -> GeneratedWelcomeMail:
    if delivery_mode not in {"local", "send", "drafts"}:
        raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
    member = repository.get_member(member_id)
    if not member.email.strip():
        raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
    data = repository.get_contributions(member_id)
    selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
    claims = [
        claim
        for claim in open_claims(repository, member_id)
        if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
    ]
    if selected_ids is not None and len(claims) != len(selected_ids):
        raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
    balances = {
        str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
    }
    target: Path | None = None
    if delivery_mode == "local":
        if not output_path:
            raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
        target = Path(output_path)
        target.parent.mkdir(parents=True, exist_ok=True)
        if target.exists():
            raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
    organization = repository.get_configuration().get("organization") or {}
    content = welcome_mail_bytes(
        repository,
        member=member,
        claims=claims,
        balances=balances,
        organization=organization,
        sender_name=sender_name,
        sender_email=sender_email,
        signature=signature,
    )
    export_path: Path | None = None
    archive_path: Path | None = None
    archive_temp: Path | None = None
    # Same rule as the dunning/SEPA mails: once the mail has reached the SMTP server,
    # the IMAP folder or the local export file, a later archiving failure must not
    # hide that it went out -- the event is written either way and the archiving
    # problem is reported afterwards.
    delivered = False
    archive_failure: Exception | None = None
    try:
        archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
        archive_dir.mkdir(parents=True, exist_ok=True)
        archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
        archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
        archive_temp.write_bytes(content)
        if delivery_mode == "local":
            export_temp = target.with_name(f".{target.name}.tmp")
            try:
                export_temp.write_bytes(content)
                os.replace(export_temp, target)
            finally:
                export_temp.unlink(missing_ok=True)
            export_path = target
            delivered = True
        elif delivery_mode == "send":
            settings = repository.get_email_settings()
            sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
            with smtp_session(settings) as smtp_client, sent_copy as imap_client:
                send_via_smtp(smtp_client, content)
                delivered = True
                if imap_client is not None:
                    ensure_imap_folder(imap_client, settings["imap_sent_folder"])
                    append_message(
                        imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
                    )
        else:
            settings = repository.get_email_settings()
            with imap_session(settings) as client:
                ensure_imap_folder(client, settings["imap_drafts_folder"])
                append_message(
                    client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
                )
                delivered = True
        os.replace(archive_temp, archive_path)
    except Exception as exc:
        if archive_temp is not None:
            archive_temp.unlink(missing_ok=True)
        if not delivered:
            raise
        archive_failure = exc

    digest = hashlib.sha256(content).hexdigest()
    references: dict[str, str] = {}
    if archive_failure is None:
        references["document"] = archive_path.relative_to(
            repository.members_root / member_id / "files"
        ).as_posix()
    event_data = {
        "recipient": member.email,
        "sha256": digest,
        "claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
        "delivery_mode": delivery_mode,
    }
    if archive_failure is not None:
        event_data["archive_error"] = str(archive_failure)
    repository.append_event(
        member_id,
        event_type="welcome_email_sent",
        summary="Willkommens- und Erstrechnungsmail erzeugt",
        actor_type="user",
        actor_name="Vorstand",
        references=references,
        data=event_data,
    )
    if archive_failure is not None:
        raise RepositoryError(
            "Die Willkommensmail wurde zugestellt bzw. abgelegt, konnte aber nicht "
            f"vollständig archiviert werden: {archive_failure}. Bitte die Mitgliedsakte "
            "manuell prüfen."
        ) from archive_failure
    return GeneratedWelcomeMail(export_path, archive_path, digest)


def _available_path(directory: Path, filename: str) -> Path:
    candidate = directory / filename
    if not candidate.exists():
        return candidate
    stem, suffix = Path(filename).stem, Path(filename).suffix
    index = 2
    while (directory / f"{stem}-{index}{suffix}").exists():
        index += 1
    return directory / f"{stem}-{index}{suffix}"
