"""The invitation greeting resolves and the message limit holds (task 4.4, design D9).

Pure unit tests: resolution and normalisation are decisions, not queries, so they need no
database and run on every commit.

The combination table is written out in full rather than generated. Four cases is few enough
to read, and reading them is the point — a generated test that asserts "resolve returns
something" would pass against a function that returned the wrong sentence every time.
"""

import pytest
from httpx import ASGITransport, AsyncClient

from app.main import app
from app.models.enums import InvitationType, Locale
from app.services import greeting

from .conftest import requires_db


@pytest.mark.parametrize(
    ("locale", "invitation_type", "expected"),
    [
        (Locale.EN, InvitationType.SINGLE, "You are cordially invited"),
        (Locale.EN, InvitationType.FAMILY, "You and your family are cordially invited"),
        (Locale.BN, InvitationType.SINGLE, "আপনাকে সাদর আমন্ত্রণ"),
        (Locale.BN, InvitationType.FAMILY, "আপনাকে ও আপনার পরিবারকে সাদর আমন্ত্রণ"),
    ],
)
def test_every_combination_has_a_default(
    locale: Locale, invitation_type: InvitationType, expected: str
) -> None:
    assert greeting.resolve({}, locale=locale, invitation_type=invitation_type) == expected


def test_a_missing_map_still_resolves() -> None:
    """A wedding seeded before this column existed reads as None, not {}."""
    assert greeting.resolve(None, locale=Locale.EN, invitation_type=InvitationType.SINGLE)


def test_partial_customisation_falls_back_per_key() -> None:
    """Customising one cell must not blank the other three.

    The realistic failure is a lookup that returns the whole locale's map or nothing, which
    would leave a host who edited only the family sentence with an empty single one.
    """
    messages = {"en": {"family": "Bring everyone"}}

    assert greeting.resolve(messages, locale=Locale.EN, invitation_type=InvitationType.FAMILY) == (
        "Bring everyone"
    )
    assert greeting.resolve(messages, locale=Locale.EN, invitation_type=InvitationType.SINGLE) == (
        "You are cordially invited"
    )
    # And Bangla is untouched — deliberately not falling back to the English custom text,
    # which would show a Bangla reader a sentence they may not read.
    assert greeting.resolve(messages, locale=Locale.BN, invitation_type=InvitationType.FAMILY) == (
        "আপনাকে ও আপনার পরিবারকে সাদর আমন্ত্রণ"
    )


def test_a_blank_custom_message_falls_back_rather_than_rendering_empty() -> None:
    """The invariant the whole design rests on: a guest never sees an empty greeting."""
    messages = {"en": {"single": "   "}}
    assert greeting.resolve(messages, locale=Locale.EN, invitation_type=InvitationType.SINGLE)


# ------------------------------------------------------------------ layered resolution


def test_the_first_layer_with_a_value_wins() -> None:
    """An event's wording beats the wedding's (design D10)."""
    event = {"en": {"single": "Join us at the Walima"}}
    wedding = {"en": {"single": "Join us"}}
    assert greeting.resolve(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    ) == ("Join us at the Walima")


def test_an_empty_layer_falls_through_to_the_next() -> None:
    """The state every existing event is in: `{}` means inherit, not "no greeting"."""
    wedding = {"en": {"single": "Join us"}}
    assert greeting.resolve(
        {}, wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    ) == ("Join us")


def test_layers_fall_through_per_key_not_per_map() -> None:
    """An event overriding only `family` must still inherit `single`.

    The realistic failure is a lookup that takes the first layer holding *anything* and stops,
    which would leave the single sentence on the built-in default while the wedding had one.
    """
    event = {"en": {"family": "Bring the whole family to the Walima"}}
    wedding = {"en": {"single": "Join us", "family": "Bring everyone"}}

    assert greeting.resolve(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.FAMILY
    ) == ("Bring the whole family to the Walima")
    assert greeting.resolve(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    ) == ("Join us")


def test_an_english_event_override_never_reaches_a_bangla_reader() -> None:
    """Only English is editable per event, so this is the leak the design must not have.

    Without the no-cross-locale rule, a Bangla-reading guest at an event with an English
    override would be shown English — a language they may not read — rather than the Bangla
    the wedding or the defaults already have.
    """
    event = {"en": {"family": "Bring everyone"}}
    wedding = {"bn": {"family": "সবাইকে নিয়ে আসুন"}}

    assert greeting.resolve(
        event, wedding, locale=Locale.BN, invitation_type=InvitationType.FAMILY
    ) == ("সবাইকে নিয়ে আসুন")


def test_a_blank_event_override_falls_through_to_the_wedding() -> None:
    """Whitespace is not a customisation at any layer, and must not shadow the one below."""
    assert greeting.resolve(
        {"en": {"single": "   "}},
        {"en": {"single": "Join us"}},
        locale=Locale.EN,
        invitation_type=InvitationType.SINGLE,
    ) == ("Join us")


def test_every_layer_empty_still_resolves_to_a_default() -> None:
    """The invariant survives layering: a guest never sees an empty greeting."""
    assert greeting.resolve(
        {}, {}, None, locale=Locale.EN, invitation_type=InvitationType.FAMILY
    ) == ("You and your family are cordially invited")


# ------------------------------------------------------------------ normalisation


def test_markup_is_stripped_not_stored() -> None:
    assert greeting.normalise("<b>Please</b> join us") == "Please join us"


def test_markup_is_stripped_before_the_length_is_judged() -> None:
    """A host pasting from a word processor is over the limit on tags they cannot see."""
    padded = f"<span style='{'x' * 200}'>Join us</span>"
    assert greeting.validate(padded) == "Join us"


def test_consecutive_blank_lines_collapse() -> None:
    assert greeting.normalise("Please\n\n\n\njoin us") == "Please\njoin us"


def test_non_breaking_spaces_and_padding_are_trimmed() -> None:
    """Invisible characters otherwise survive the trim and count against the limit."""
    padded = "\u00a0 Please \u00a0join  us \u00a0"
    assert greeting.normalise(padded) == "Please join us"


def test_a_message_over_the_limit_is_rejected() -> None:
    with pytest.raises(greeting.MessageTooLongError) as caught:
        greeting.validate("x" * (greeting.MAX_MESSAGE_LENGTH + 1))
    # The message has to explain itself: the host is being told no by a number.
    assert str(greeting.MAX_MESSAGE_LENGTH) in str(caught.value)


def test_a_message_exactly_at_the_limit_is_accepted() -> None:
    """Off-by-one at the boundary is the whole risk of a cap like this."""
    at_limit = "x" * greeting.MAX_MESSAGE_LENGTH
    assert greeting.validate(at_limit) == at_limit


def test_a_full_length_bangla_message_is_accepted() -> None:
    """The cap counts characters, so it must not measure Bangla in bytes.

    A Bangla character is three UTF-8 bytes. A byte-based limit would cut this sentence off
    at a third of its allowance — and the whole point of D9's caveat is that Bangla is the
    case the English-only check misses.
    """
    message = "আপনাকে" * 16  # 96 characters, 288 bytes
    assert len(message) <= greeting.MAX_MESSAGE_LENGTH
    assert greeting.validate(message) == message


# ------------------------------------------------------------------ the whole map


def test_unknown_locales_and_types_are_dropped() -> None:
    cleaned = greeting.validate_messages(
        {"en": {"single": "Hello", "couple": "?"}, "fr": {"single": "Bonjour"}}
    )
    assert cleaned == {"en": {"single": "Hello"}}


def test_clearing_a_message_removes_it_so_the_default_returns() -> None:
    cleaned = greeting.validate_messages({"en": {"single": "", "family": "Bring everyone"}})
    assert cleaned == {"en": {"family": "Bring everyone"}}
    assert greeting.resolve(cleaned, locale=Locale.EN, invitation_type=InvitationType.SINGLE) == (
        "You are cordially invited"
    )


def test_one_bad_message_rejects_the_whole_save() -> None:
    """Partial acceptance would leave the host looking at a screen that disagrees with what
    was stored — they fix the long sentence and unknowingly lose the short one."""
    with pytest.raises(greeting.MessageTooLongError):
        greeting.validate_messages(
            {"en": {"single": "Fine", "family": "y" * (greeting.MAX_MESSAGE_LENGTH + 1)}}
        )


# ------------------------------------------------------------------ through the API


@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
@requires_db
async def test_the_messages_endpoint_rejects_an_over_limit_message() -> None:
    """The cap has to hold at the boundary the host actually touches, not only in the
    service — a route that forgot to call `validate` would still pass every test above."""
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.put(
            "/api/admin/wedding/invitation-messages",
            json={"messages": {"en": {"single": "x" * 101}}},
        )
    assert response.status_code == 422
    assert "100" in response.json()["detail"]


@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
@requires_db
async def test_the_messages_endpoint_stores_submitted_markup_as_text() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.put(
            "/api/admin/wedding/invitation-messages",
            json={"messages": {"en": {"family": "<script>alert(1)</script>Bring everyone"}}},
        )
        assert response.status_code == 200, response.text
        stored = response.json()["invitation_messages"]["en"]["family"]
        assert stored == "alert(1)Bring everyone"
        assert "<" not in stored

        # Put it back, so a run against the dev database leaves the wedding as it found it.
        await client.put("/api/admin/wedding/invitation-messages", json={"messages": {}})
