from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

SUBJECT_HEADER = "Betreff:"
BUILTIN_TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "assets" / "mail_templates"
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
# A repeat block either wraps a single line ("{{#claims}}- {{claim.title}}{{/claims}}")
# or spans several lines with the markers on their own lines; both render one copy of
# the block per entry.
# Matches a single repeat marker, opening or closing. The placeholder pattern
# deliberately does not, so unbalanced markers need a check of their own -- they
# would otherwise survive validation and end up verbatim in a sent mail.
MARKER_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
BLOCK_PATTERN = re.compile(
    r"\{\{\s*#\s*([a-z][a-z0-9_.]*)\s*\}\}[ \t]*\n?(.*?)\{\{\s*/\s*\1\s*\}\}[ \t]*(\n?)",
    re.IGNORECASE | re.DOTALL,
)


class MailTemplateError(RuntimeError):
    pass


@dataclass(frozen=True, slots=True)
class MailTemplate:
    subject: str
    body: str


@dataclass(frozen=True, slots=True)
class MailTemplateBlock:
    name: str
    description: str
    # Placeholder name -> short explanation, valid inside this block only.
    placeholders: tuple[tuple[str, str], ...]
    # The line the inserted block starts out with. It makes the inserted snippet a
    # working block that renders real content -- the board edits this line rather
    # than having to write the whole construct by hand.
    sample: str

    @property
    def snippet(self) -> str:
        return f"{{{{#{self.name}}}}}\n{self.sample}\n{{{{/{self.name}}}}}\n"


@dataclass(frozen=True, slots=True)
class PlaceholderEntry:
    """One row of the options dialog's placeholder chooser: what it reads as, and
    what double-clicking it puts into the template."""

    label: str
    snippet: str
    description: str
    indented: bool = False


@dataclass(frozen=True, slots=True)
class MailTemplateSpec:
    key: str
    label: str
    filename: str
    description: str
    # Placeholder name -> short explanation, shown as editing help in the options
    # dialog. The mail services must supply exactly these keys, so this doubles as
    # the contract a stored template is validated against.
    placeholders: tuple[tuple[str, str], ...]
    blocks: tuple[MailTemplateBlock, ...] = ()


BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
    ("member.first_name", "Vorname"),
    ("member.last_name", "Nachname"),
    ("member.full_name", "Vor- und Nachname"),
    ("member.nickname", "Nickname"),
    ("member.number", "Mitgliedsnummer"),
    ("member.email", "E-Mail-Adresse"),
    ("member.status", "Mitgliedsstatus"),
    ("member.accepted_at", "Datum des Aufnahmebeschlusses"),
    ("member.started_at", "Mitglied seit"),
    ("member.application_date", "Antragsdatum"),
    ("member.payment_frequency", "Zahlweise"),
    ("member.address_line", "Anschrift in einer Zeile"),
    ("member.iban", "IBAN des Mitglieds"),
    ("member.mandate_reference", "Mandatsreferenz"),
    ("member.mandate_active", "Lastschriftmandat aktiv (Ja/Nein)"),
    ("organization.name", "Vereinsname"),
    ("organization.address_line", "Anschrift des Vereins"),
    ("organization.email", "E-Mail-Adresse des Vereins"),
    ("organization.website", "Website des Vereins"),
    ("organization.iban", "IBAN des Vereins"),
    ("organization.bic", "BIC des Vereins"),
    ("organization.creditor_id", "Gläubiger-Identifikationsnummer"),
    ("current_date", "Heutiges Datum"),
    ("signature", "Signatur aus dem Versandfenster"),
)

CLAIM_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
    ("claim.title", "Bezeichnung der Forderung"),
    ("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
    ("claim.due_date", "Fälligkeitsdatum"),
    ("claim.amount", "Gesamtbetrag der Forderung"),
    ("claim.balance", "Noch offener Betrag"),
)

MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
    MailTemplateSpec(
        key="welcome",
        label="Willkommen & Erstrechnung",
        filename="willkommen.txt",
        description=(
            "Begrüßung neuer Mitglieder mit Mitgliedsnummer, Beginn der Mitgliedschaft "
            "und den ersten offenen Forderungen (Aufnahmegebühr und erster Beitrag)."
        ),
        placeholders=BASE_PLACEHOLDERS
        + (
            (
                "membership.start_date",
                "Beginn der Mitgliedschaft; ist noch keiner erfasst, der 1. des auf den "
                "Aufnahmebeschluss folgenden Monats",
            ),
            ("claims.list", "Alle ausgewählten Forderungen als Liste, eine je Zeile"),
            ("claims.total", "Summe der ausgewählten Forderungen"),
            ("claims.count", "Anzahl der ausgewählten Forderungen"),
            ("claims.first_due_date", "Früheste Fälligkeit der ausgewählten Forderungen"),
            ("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
            ("payment.reference", "Vorgeschlagener Verwendungszweck"),
        ),
        blocks=(
            MailTemplateBlock(
                name="claims",
                description="Wiederholt sich je ausgewählter Forderung",
                placeholders=CLAIM_ITEM_PLACEHOLDERS,
                sample="{{claim.description}} (fällig {{claim.due_date}}): {{claim.balance}} Euro",
            ),
        ),
    ),
    MailTemplateSpec(
        key="reminder",
        label="Mahnung",
        filename="mahnung.txt",
        description=(
            "Begleitmail zu einer Mahnung bzw. Zahlungserinnerung. Wird beim Versand "
            "mit der jeweiligen Mahnstufe, Frist und den Mahnpositionen gefüllt."
        ),
        placeholders=BASE_PLACEHOLDERS
        + CLAIM_ITEM_PLACEHOLDERS
        + (
            ("reminder.name", "Bezeichnung der Mahnstufe"),
            ("reminder.level", "Nummer der Mahnstufe"),
            ("reminder.payment_deadline", "Neue Zahlungsfrist"),
            ("reminder.detail", "Freitext-Hinweis der Mahnung"),
            ("reminder.hint", "Hinweiszeile, nur gefüllt wenn ein Freitext hinterlegt ist"),
            ("reminder.items", "Mahnpositionen als Liste, eine je Zeile"),
            ("reminder.fee_total", "Summe der Mahnpositionen"),
            ("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
            ("payment.reference", "Vorgeschlagener Verwendungszweck"),
        ),
        blocks=(
            MailTemplateBlock(
                name="reminder.items",
                description="Wiederholt sich je Mahnposition",
                placeholders=(
                    ("item.description", "Bezeichnung der Position"),
                    ("item.amount", "Betrag der Position"),
                ),
                sample="{{item.description}}: {{item.amount}} Euro",
            ),
        ),
    ),
    MailTemplateSpec(
        key="sepa",
        label="SEPA-Vorabinformation",
        filename="sepa-info.txt",
        description=(
            "Vorabinformation zu einem geplanten Lastschrifteinzug mit Betrag, "
            "Einzugsdatum und Mandatsdaten."
        ),
        placeholders=BASE_PLACEHOLDERS
        + (
            ("debit.amount", "Einzuziehender Betrag"),
            ("debit.collection_date", "Einzugsdatum"),
            ("debit.purpose", "Verwendungszweck der Lastschrift"),
            ("debit.mandate_reference", "Mandatsreferenz"),
        ),
    ),
)


def placeholder_entries(key: str) -> list[PlaceholderEntry]:
    """Rows for the placeholder chooser. Repeat blocks contribute a complete,
    ready-to-edit block -- inserting one has to leave a working template behind,
    not a description of one."""
    spec = template_spec(key)
    entries = [
        PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description)
        for name, description in spec.placeholders
    ]
    for block in spec.blocks:
        entries.append(
            PlaceholderEntry(
                f"{{{{#{block.name}}}}} … {{{{/{block.name}}}}}", block.snippet, block.description
            )
        )
        entries.extend(
            PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description, indented=True)
            for name, description in block.placeholders
        )
    return entries


def template_spec(key: str) -> MailTemplateSpec:
    for spec in MAIL_TEMPLATES:
        if spec.key == key:
            return spec
    raise MailTemplateError(f"Unbekannte E-Mail-Vorlage: {key}")


def default_mail_template(key: str) -> MailTemplate:
    spec = template_spec(key)
    path = BUILTIN_TEMPLATE_ROOT / spec.filename
    try:
        return parse_mail_template(path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise MailTemplateError(
            f"Die mitgelieferte Vorlage „{spec.label}“ konnte nicht gelesen werden: {exc}"
        ) from exc


def parse_mail_template(text: str) -> MailTemplate:
    """Templates are plain text files whose first line carries the subject
    ("Betreff: ..."), followed by a blank line and the message body -- readable and
    editable with any text editor, just like the rest of the store."""
    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    subject = ""
    if lines and lines[0].strip().casefold().startswith(SUBJECT_HEADER.casefold()):
        subject = lines[0].split(":", 1)[1].strip()
        lines = lines[1:]
        if lines and not lines[0].strip():
            lines = lines[1:]
    return MailTemplate(subject, "\n".join(lines).strip("\n"))


def serialize_mail_template(subject: str, body: str) -> str:
    normalized = body.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
    return f"{SUBJECT_HEADER} {subject.strip()}\n\n{normalized}\n"


def render_mail_template(
    template: MailTemplate,
    values: dict[str, str],
    repeats: dict[str, list[dict[str, str]]] | None = None,
) -> MailTemplate:
    subject = _render_line(template.subject, values)
    body = _render_blocks(template.body, values, repeats or {})
    return MailTemplate(subject.strip(), _render_text(body, values))


def validate_block_markers(key: str, subject: str, body: str) -> None:
    """Checks that every {{#block}} is closed by its own {{/block}} and that no
    stray marker is left over. An unbalanced marker is not a placeholder, so it
    renders as itself -- the recipient would read "{{#claims}}" in their mail."""
    spec = template_spec(key)
    known = {block.name.casefold(): block.name for block in spec.blocks}
    for match in MARKER_PATTERN.finditer(subject):
        raise MailTemplateError(
            f"Wiederholungsblöcke sind im Betreff nicht möglich: {match.group(0)}"
        )
    open_marker: tuple[str, str] | None = None
    for match in MARKER_PATTERN.finditer(body):
        kind, name = match.group(1), match.group(2)
        if name.casefold() not in known:
            raise MailTemplateError(
                f"Unbekannter Wiederholungsblock: {match.group(0)}. Verfügbar: "
                + (", ".join(f"{{{{#{value}}}}}" for value in known.values()) or "keine")
            )
        if kind == "#":
            if open_marker is not None:
                raise MailTemplateError(
                    f"Wiederholungsblöcke können nicht ineinander verschachtelt werden: "
                    f"{{{{#{open_marker[1]}}}}} ist noch offen, als {match.group(0)} beginnt."
                )
            open_marker = (kind, name)
            continue
        if open_marker is None:
            raise MailTemplateError(
                f"{match.group(0)} steht ohne zugehöriges {{{{#{name}}}}} in der Vorlage."
            )
        if open_marker[1].casefold() != name.casefold():
            raise MailTemplateError(
                f"{{{{#{open_marker[1]}}}}} wird durch {match.group(0)} geschlossen. "
                f"Erwartet wird {{{{/{open_marker[1]}}}}}."
            )
        open_marker = None
    if open_marker is not None:
        raise MailTemplateError(
            f"{{{{#{open_marker[1]}}}}} wird nicht geschlossen. Es fehlt "
            f"{{{{/{open_marker[1]}}}}}."
        )


def validate_mail_template(key: str, subject: str, body: str) -> None:
    """Rejects placeholders the mail service will never supply, so a typo surfaces
    while editing the template instead of during a send run."""
    spec = template_spec(key)
    validate_block_markers(key, subject, body)
    known = {name for name, _help in spec.placeholders}
    block_names = {block.name for block in spec.blocks}
    unknown: set[str] = set()
    for block in spec.blocks:
        block_known = known | {name for name, _help in block.placeholders}
        for match in _blocks_of(body, block.name):
            unknown.update(name for name in _placeholder_names(match) if name not in block_known)
    remaining = body
    for block_name in block_names:
        remaining = _strip_blocks(remaining, block_name)
    unknown.update(name for name in _placeholder_names(remaining) if name not in known)
    unknown.update(name for name in _placeholder_names(subject) if name not in known)
    if unknown:
        raise MailTemplateError(
            "Unbekannte Platzhalter in der Vorlage: " + ", ".join(sorted(unknown))
        )
    if not subject.strip():
        raise MailTemplateError("Die Vorlage benötigt einen Betreff.")
    if not body.strip():
        raise MailTemplateError("Die Vorlage benötigt einen Text.")


def _placeholder_names(text: str) -> set[str]:
    return {match.group(1) for match in PLACEHOLDER_PATTERN.finditer(text)}


def _blocks_of(text: str, name: str) -> list[str]:
    return [
        match.group(2)
        for match in BLOCK_PATTERN.finditer(text)
        if match.group(1).casefold() == name.casefold()
    ]


def _strip_blocks(text: str, name: str) -> str:
    return BLOCK_PATTERN.sub(
        lambda match: "" if match.group(1).casefold() == name.casefold() else match.group(0), text
    )


def _render_blocks(
    body: str, values: dict[str, str], repeats: dict[str, list[dict[str, str]]]
) -> str:
    def expand(match: re.Match[str]) -> str:
        name = match.group(1)
        entries = next(
            (rows for key, rows in repeats.items() if key.casefold() == name.casefold()), None
        )
        if entries is None:
            return match.group(0)
        block = match.group(2)
        if block.endswith("\n"):
            block = block[:-1]
        rendered = [_render_text(block, {**values, **entry}) for entry in entries]
        if not rendered:
            return ""
        return "\n".join(rendered) + match.group(3)

    return BLOCK_PATTERN.sub(expand, body)


def _render_text(text: str, values: dict[str, str]) -> str:
    # A line that holds nothing but placeholders which all render empty (an optional
    # hint, an empty list) is dropped instead of leaving a stray blank line behind.
    rendered = []
    for line in text.split("\n"):
        replaced = _render_line(line, values)
        if replaced.strip() or not line.strip() or not _placeholder_names(line):
            rendered.append(replaced)
            continue
        if PLACEHOLDER_PATTERN.sub("", line).strip():
            rendered.append(replaced)
    return "\n".join(rendered)


def _render_line(line: str, values: dict[str, str]) -> str:
    return PLACEHOLDER_PATTERN.sub(
        lambda match: values.get(match.group(1), match.group(0)), line
    )
