from __future__ import annotations

import hashlib
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path

from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
    MEMBERSHIP_STATUS_LABELS,
    PAYMENT_FREQUENCY_LABELS,
    Member,
)
from ccma.services.mail_composition import compose_mail, 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

# Everyone the club still has a live relationship with. An ended membership is not
# asked to check data the club is about to delete anyway, and an application has not
# been decided on yet -- both can still be picked by hand in the dialog.
DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = (
    "accepted_pending_payment",
    "active",
    "suspended_contribution",
    "resigned_end_of_year",
    "honorary",
)

# A field the member is meant to complete reads better as a visible gap than as an
# empty line the eye skips over.
NOT_STORED = "(nicht hinterlegt)"

# Only makes sense next to a masked IBAN, so it is filled per member rather than
# written into the template as a fixed line. It sits directly under the record and
# reads as a footnote to it, which is also why it carries its own parentheses: a
# dropped line must not leave a blank one behind.
IBAN_HINT = (
    "(Die IBAN zeigen wir absichtlich nur verkürzt an – die letzten vier Stellen "
    "genügen zum Abgleich.)"
)


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


def _safe_filename(value: str) -> str:
    cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
    return cleaned or "Mitglied"


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}"


def mask_iban(iban: str) -> str:
    """Shows enough of the IBAN to recognise the account (country code and the last
    four digits) without putting the full account number into an e-mail: the member
    only has to confirm it is still the right account."""
    cleaned = iban.replace(" ", "").strip()
    if len(cleaned) < 8:
        return cleaned
    return f"{cleaned[:2]}{'*' * (len(cleaned) - 6)}{cleaned[-4:]}"


def member_data_fields(member: Member) -> list[tuple[str, str]]:
    """The stored record as the member gets to see it: label and value, in the order
    they read naturally. Bank details are only listed for members who have any --
    asking someone who pays by transfer to check a mandate they never gave is noise."""
    fields: list[tuple[str, str]] = [
        ("Mitgliedsnummer", member.member_number),
        ("Name", " ".join(part for part in (member.first_name, member.last_name) if part)),
        ("Nickname", member.nickname),
        ("Geburtsdatum", format_date_for_display(member.birth_date)),
        ("E-Mail", member.email),
        ("Telefon", member.phone),
        ("Straße und Hausnummer", member.street),
    ]
    if member.address_addition.strip():
        fields.append(("Adresszusatz", member.address_addition))
    fields.extend(
        [
            (
                "PLZ und Ort",
                " ".join(part for part in (member.postal_code, member.city) if part),
            ),
            ("Land", member.country),
            ("Mitgliedsstatus", MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)),
            ("Mitglied seit", format_date_for_display(member.membership_started_at)),
            (
                "Zahlweise",
                PAYMENT_FREQUENCY_LABELS.get(
                    member.payment_frequency, member.payment_frequency
                ).capitalize(),
            ),
        ]
    )
    if has_bank_details(member):
        signed = format_date_for_display(member.mandate_signed_at)
        fields.extend(
            [
                ("Kontoinhaber", member.account_holder),
                ("IBAN", mask_iban(member.iban)),
                ("Mandatsreferenz", member.mandate_reference),
                (
                    "Lastschriftmandat",
                    ("aktiv" if member.mandate_active else "nicht aktiv")
                    + (f", erteilt am {signed}" if signed else ""),
                ),
            ]
        )
    return [(label, value.strip() or NOT_STORED) for label, value in fields]


def has_bank_details(member: Member) -> bool:
    """Whether the club stores anything about this member's account at all. Members
    who pay by transfer have nothing here, and asking them to check a mandate they
    never gave is noise."""
    return any(
        value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
    )


def payment_reference(member: Member) -> str:
    """What the member should write on a transfer. Both halves are offered because
    either one identifies the payment on its own: a member who has forgotten their
    number can fall back on the name, and a name shared by two members is told apart
    by the number."""
    return " – ".join(
        part
        for part in (
            member.member_number.strip(),
            " ".join(value for value in (member.first_name, member.last_name) if value).strip(),
        )
        if part
    )


def data_review_recipients(
    repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
) -> list[Member]:
    """The members a data-review run is meant for by default -- an e-mail address is
    required, so members without one are left out here and reported by the dialog."""
    wanted = set(statuses if statuses is not None else DEFAULT_RECIPIENT_STATUSES)
    return [
        member
        for member in repository.list_members()
        if member.status in wanted and member.email.strip()
    ]


def data_review_mail_bytes(
    repository: MemberRepository,
    *,
    member: Member,
    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)
    fields = member_data_fields(member)
    entries = [{"field.label": label, "field.value": value} for label, value in fields]
    values.update(
        {
            "member.iban_masked": mask_iban(member.iban),
            "data.sheet": "\n".join(f"{label}: {value}" for label, value in fields),
            "data.count": str(len(fields)),
            "data.iban_hint": IBAN_HINT if has_bank_details(member) else "",
            "payment.reference": payment_reference(member),
        }
    )
    rendered = render_template(repository, "data_review", values, {"data": 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_data_review_mails(
    repository: MemberRepository,
    member_ids: list[str],
    *,
    delivery_mode: str,
    output_directory: Path | str | None = None,
    sender_name: str,
    sender_email: str,
    signature: str,
    today: date | None = None,
) -> tuple[list[GeneratedDataReviewMail], list[str]]:
    """Sends one data-review mail per member. A member whose mail fails is reported as
    a warning and the run continues: a single bad address must not stop a mailing to
    the whole club halfway through."""
    repository.assert_writable()
    output: Path | None = None
    email_settings: dict | None = None
    smtp_ctx = nullcontext(None)
    imap_ctx = nullcontext(None)
    if delivery_mode == "local":
        if not output_directory:
            raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
        output = Path(output_directory)
        output.mkdir(parents=True, exist_ok=True)
    elif delivery_mode == "send":
        email_settings = repository.get_email_settings()
        smtp_ctx = smtp_session(email_settings)
        if email_settings["imap_sent_enabled"]:
            imap_ctx = imap_session(email_settings)
    elif delivery_mode == "drafts":
        email_settings = repository.get_email_settings()
        imap_ctx = imap_session(email_settings)
    else:
        raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")

    organization = repository.get_configuration().get("organization") or {}
    stamp = (today or date.today()).isoformat()
    generated: list[GeneratedDataReviewMail] = []
    warnings: list[str] = []
    # One connection for the whole run -- this mailing covers every member, so
    # reconnecting per recipient would be slow and invites provider rate limits.
    with smtp_ctx as smtp_client, imap_ctx as imap_client:
        if delivery_mode == "drafts":
            ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
        elif delivery_mode == "send" and imap_client is not None:
            ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
        for member_id in member_ids:
            try:
                member = repository.get_member(member_id)
            except RepositoryError as exc:
                warnings.append(f"{member_id}: {exc}")
                continue
            label = member.member_number or member.display_name
            if not member.email.strip():
                warnings.append(f"{label}: E-Mail-Adresse fehlt.")
                continue
            export_path: Path | None = None
            archive_path: Path | None = None
            # Once this mail has left the building, a later archiving failure must not
            # hide that it went out, nor abort the members that come after it.
            delivered = False
            archive_failure: Exception | None = None
            try:
                content = data_review_mail_bytes(
                    repository,
                    member=member,
                    organization=organization,
                    sender_name=sender_name,
                    sender_email=sender_email,
                    signature=signature,
                )
                filename = f"Datenpruefung-{stamp}-{_safe_filename(label)}.eml"
                archive_dir = (
                    repository.members_root
                    / member.member_id
                    / "files"
                    / "documents"
                    / "Datenpruefung"
                )
                archive_dir.mkdir(parents=True, exist_ok=True)
                archive_path = _available_path(archive_dir, filename)
                if delivery_mode == "local":
                    export_path = _available_path(output, filename)
                    export_path.write_bytes(content)
                    delivered = True
                elif delivery_mode == "send":
                    send_via_smtp(smtp_client, content)
                    delivered = True
                    if imap_client is not None:
                        append_message(
                            imap_client,
                            content,
                            folder=email_settings["imap_sent_folder"],
                            flags=r"(\Seen)",
                        )
                else:
                    append_message(
                        imap_client,
                        content,
                        folder=email_settings["imap_drafts_folder"],
                        flags=r"(\Draft)",
                    )
                    delivered = True
                archive_path.write_bytes(content)
            except (OSError, RepositoryError) as exc:
                if not delivered:
                    if export_path is not None:
                        export_path.unlink(missing_ok=True)
                    warnings.append(f"{label}: {exc}")
                    continue
                warnings.append(
                    f"{label}: E-Mail wurde versandt/abgelegt, konnte aber nicht archiviert "
                    f"werden ({exc}); bitte manuell prüfen."
                )
                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.member_id / "files"
                ).as_posix()
            data = {
                "sha256": digest,
                "recipient": member.email,
                "delivery_mode": delivery_mode,
                "field_count": len(member_data_fields(member)),
            }
            if archive_failure is not None:
                data["archive_error"] = str(archive_failure)
            repository.append_event(
                member.member_id,
                event_type="data_review_email_sent",
                summary="Bitte um Datenüberprüfung verschickt",
                actor_type="user",
                actor_name="Vorstand",
                references=references,
                data=data,
            )
            generated.append(
                GeneratedDataReviewMail(
                    member.member_id, member.email, export_path, archive_path
                )
            )
    return generated, warnings
