"""The invitation sentence shown above the card (tasks 4.2, 4.3, design D9).

Two moving parts, deliberately kept together because they have to agree:

* `normalise` decides what a customer is allowed to store. The stored value is rendered as
  plain text into a page the couple's guests read, so it is cleaned on the way in rather
  than escaped on the way out — one write path is easier to hold correct than every read.
* `resolve` decides what a guest actually sees. It falls through customer message → built-in
  default, so a missing key produces a real sentence rather than an empty header. Returning
  None or "" is not a state any caller has to handle, because it cannot happen.

The 100-character cap is a design constraint, not a database one: the greeting sits above
artwork whose proportions the design team fixed, and a paragraph there breaks the
composition. It is calibrated on English — Bangla says the same thing in fewer characters
but wider glyphs, so an at-limit Bangla message is the case that overflows first (D9).
"""

import re
import unicodedata

from app.models.enums import InvitationType, Locale

#: Characters, not bytes: the limit protects a line of layout, and one Bangla grapheme
#: occupies a line the same way one Latin letter does.
MAX_MESSAGE_LENGTH = 100

#: Rendered when a customer has not written their own. Every locale and type combination has
#: one, which is what makes an empty greeting unreachable.
DEFAULTS: dict[Locale, dict[InvitationType, str]] = {
    Locale.EN: {
        InvitationType.SINGLE: "You are cordially invited",
        InvitationType.FAMILY: "You and your family are cordially invited",
    },
    Locale.BN: {
        InvitationType.SINGLE: "আপনাকে সাদর আমন্ত্রণ",
        InvitationType.FAMILY: "আপনাকে ও আপনার পরিবারকে সাদর আমন্ত্রণ",
    },
}

_MARKUP = re.compile(r"<[^>]*>")
_BLANK_LINES = re.compile(r"\n{2,}")
# The class includes U+00A0, written as an escape because a non-breaking space in source
# is invisible and reads as a typo. It has to be handled: pasting from a word processor
# brings them in, and left alone they defeat both the trim and the character count.
_HORIZONTAL_SPACE = re.compile("[ \t\u00a0]+")


class MessageTooLongError(ValueError):
    """Raised when a message exceeds the cap *after* normalisation.

    Measured after, not before: a customer who pastes formatted text from a document is over
    the limit on markup they cannot see, and rejecting that would be unexplainable.
    """

    def __init__(self, length: int) -> None:
        super().__init__(
            f"Message is {length} characters after cleaning; the limit is "
            f"{MAX_MESSAGE_LENGTH}. The limit keeps the greeting from crowding the card."
        )
        self.length = length


def normalise(raw: str) -> str:
    """Clean a customer-supplied message into the plain text that will be stored.

    Markup is stripped rather than rejected. A host pasting from Word arrives with `<span>`
    wrappers they never typed and cannot see; refusing the save would blame them for
    something invisible. Anything that survives is inert text either way, because the
    renderer never treats this value as markup.
    """
    # NFC first: the same Bangla sentence can arrive composed or decomposed, and the two
    # forms count differently against a character limit while looking identical.
    text = unicodedata.normalize("NFC", raw)
    text = _MARKUP.sub("", text)
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = _BLANK_LINES.sub("\n", text)
    text = _HORIZONTAL_SPACE.sub(" ", text)
    return "\n".join(line.strip() for line in text.split("\n")).strip()


def validate(raw: str) -> str:
    """Normalise and enforce the cap. Returns the value to store."""
    cleaned = normalise(raw)
    if len(cleaned) > MAX_MESSAGE_LENGTH:
        raise MessageTooLongError(len(cleaned))
    return cleaned


def validate_messages(messages: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
    """Validate a whole `{locale: {type: message}}` map, dropping unknown keys.

    Unknown locales and types are discarded rather than rejected: the stored JSONB is only
    ever read through `resolve`, which asks for known keys, so an unrecognised one is dead
    weight that would otherwise accumulate forever.

    An empty string clears a customisation — the guest then sees the built-in default, which
    is the only sensible reading of a host emptying the field.
    """
    cleaned: dict[str, dict[str, str]] = {}
    for locale_key, by_type in messages.items():
        try:
            locale = Locale(locale_key)
        except ValueError:
            continue
        for type_key, message in by_type.items():
            try:
                invitation_type = InvitationType(type_key)
            except ValueError:
                continue
            value = validate(message)
            if value:
                cleaned.setdefault(locale.value, {})[invitation_type.value] = value
    return cleaned


def resolve(
    *layers: dict[str, dict[str, str]] | None,
    locale: Locale,
    invitation_type: InvitationType,
) -> str:
    """The sentence this guest sees. Never empty.

    Takes any number of message maps in priority order, most specific first, and falls through
    them to the built-in default for this locale and type (design D10):

        resolve(event.invitation_messages, wedding.invitation_messages, locale=…, …)

    Variadic rather than a second function so the one-map call form keeps working unchanged —
    an event that stores nothing is simply a layer that misses, which is the same shape as the
    wedding storing nothing.

    It deliberately does not fall back across locales: showing an English sentence to a Bangla
    reader because the host customised only English would be worse than the Bangla default,
    which at least reads correctly. That matters more now that English is customisable per
    event and Bangla is not — without this rule, an event's English override would leak into
    every Bangla invitation under it.
    """
    for messages in layers:
        custom = (messages or {}).get(locale.value, {}).get(invitation_type.value, "").strip()
        if custom:
            return custom
    by_type = DEFAULTS.get(locale) or DEFAULTS[Locale.EN]
    return by_type.get(invitation_type) or DEFAULTS[Locale.EN][InvitationType.SINGLE]
