"""Composing and rendering a batched invitation (add-bulk-invitation-send D3, D4, D11).

Pure unit tests: composition takes an event and a wedding and touches no database, because
no guest is involved — that is the whole difference from the manual path. The guest-specific
parts stay as placeholders and are substituted per recipient later.

What these guard is the pair of failures that make a batch worse than a single send. One is
drift: if `compose_bulk` resolved the invitation sentence differently from `compose_manual`,
the admin's preview and the guest's page would disagree 300 times instead of once. The other
is a bad placeholder reaching everyone, which is unrecoverable in a way a single send is not.
"""

import uuid
from datetime import UTC, datetime, timedelta

import pytest

from app.models import Event, Guest, Invitation, Wedding
from app.models.enums import Channel, EventType, InvitationType, Locale
from app.services import messaging


def _wedding(messages: dict[str, dict[str, str]] | None = None) -> Wedding:
    return Wedding(
        id=uuid.uuid4(),
        bride_name="Ayesha",
        groom_name="Rahim",
        slug="ayesha-rahim",
        timezone="Asia/Dhaka",
        invitation_messages=messages or {},
    )


def _event(wedding: Wedding, messages: dict[str, dict[str, str]] | None = None) -> Event:
    event = Event(
        id=uuid.uuid4(),
        wedding_id=wedding.id,
        type=EventType.WALIMA,
        slug="walima",
        title_bn="ওয়ালিমা",
        title_en="Walima",
        starts_at=datetime.now(UTC) + timedelta(days=30),
        venue_name="Test Hall",
        venue_address="Dhaka",
        host_name_1="Abdul Karim",
        host_phone="+8801711223344",
        invitation_messages=messages or {},
    )
    event.wedding = wedding
    return event


def _template(
    *,
    subject: str = "You're invited — Walima",
    header: str = "Dear {guest_name}",
    body: str = "Please come.\n\n{invitation_link}",
    footer: str = "Regards\nAyesha & Rahim",
) -> messaging.ComposedTemplate:
    return messaging.ComposedTemplate(subject=subject, header=header, body=body, footer=footer)


# ------------------------------------------------------------------ composition


def test_the_single_pane_carries_the_single_message() -> None:
    wedding = _wedding()
    event = _event(wedding, {"en": {"single": "Come and eat", "family": "Bring everyone"}})
    composed = messaging.compose_bulk(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    )
    assert "Come and eat" in composed.body
    assert "Bring everyone" not in composed.body


def test_the_family_pane_carries_the_family_message() -> None:
    wedding = _wedding()
    event = _event(wedding, {"en": {"single": "Come and eat", "family": "Bring everyone"}})
    composed = messaging.compose_bulk(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.FAMILY
    )
    assert "Bring everyone" in composed.body
    assert "Come and eat" not in composed.body


def test_the_events_message_beats_the_wedding_wide_one() -> None:
    wedding = _wedding({"en": {"single": "Wedding wide"}})
    event = _event(wedding, {"en": {"single": "Walima only"}})
    composed = messaging.compose_bulk(
        event, wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    )
    assert "Walima only" in composed.body
    assert "Wedding wide" not in composed.body


def test_the_built_in_default_is_used_when_neither_stores_anything() -> None:
    wedding = _wedding()
    composed = messaging.compose_bulk(
        _event(wedding), wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    )
    assert "You are cordially invited" in composed.body


def test_a_bangla_pane_is_bangla_throughout_with_no_english_leak() -> None:
    """An English override must not reach a Bangla reader (greeting D10, D11)."""
    wedding = _wedding()
    event = _event(wedding, {"en": {"single": "English only"}})
    composed = messaging.compose_bulk(
        event, wedding, locale=Locale.BN, invitation_type=InvitationType.SINGLE
    )
    assert composed.header.startswith("প্রিয়")
    assert "শুভেচ্ছান্তে" in composed.footer
    assert "English only" not in composed.body
    assert "আপনাকে সাদর আমন্ত্রণ" in composed.body
    assert "ওয়ালিমা" in composed.subject


def test_composition_leaves_the_guest_parts_as_placeholders() -> None:
    """No guest is involved, so nothing guest-specific may be resolved yet (D3)."""
    wedding = _wedding()
    composed = messaging.compose_bulk(
        _event(wedding), wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    )
    assert composed.header == "Dear {guest_name}"
    assert "{invitation_link}" in composed.body


def test_the_footer_and_subject_are_resolved_because_they_are_constant() -> None:
    """The host's name and the event title are the same for everyone — editing words, not
    template syntax, is the point of resolving them now."""
    wedding = _wedding()
    composed = messaging.compose_bulk(
        _event(wedding), wedding, locale=Locale.EN, invitation_type=InvitationType.SINGLE
    )
    assert composed.footer == "Regards\nAyesha & Rahim"
    assert composed.subject == "You're invited — Walima"


@pytest.mark.parametrize("locale", [Locale.EN, Locale.BN])
@pytest.mark.parametrize("invitation_type", [InvitationType.SINGLE, InvitationType.FAMILY])
def test_the_bulk_and_manual_paths_compose_the_same_sentence(
    locale: Locale, invitation_type: InvitationType
) -> None:
    """Task 2.2 — the two must not drift.

    A batched preview that resolved the sentence differently from the single-guest panel
    would mean the same event says two different things depending on which screen the host
    used, and nothing in the system would notice.
    """
    wedding = _wedding({"en": {"family": "Wedding wide family"}})
    event = _event(wedding, {"en": {"single": "Walima single"}})

    guest = Guest(
        id=uuid.uuid4(),
        wedding_id=wedding.id,
        event_id=event.id,
        full_name="Harun Abdullah",
        email="guest@example.test",
        preferred_locale=locale,
        invitation_type=invitation_type,
    )
    invitation = Invitation(
        id=uuid.uuid4(),
        guest_id=guest.id,
        event_id=event.id,
        token="YYsasBj5-L0UcOfwSCbIqA",
        short_code="U24D8K",
        max_guests=4,
    )
    invitation.guest = guest
    invitation.event = event

    manual = messaging.compose_manual(invitation)
    bulk = messaging.compose_bulk(event, wedding, locale=locale, invitation_type=invitation_type)

    # The manual body is "<sentence>\n\n<url>"; the bulk body is "<sentence>\n\n{placeholder}".
    assert manual.body.rsplit("\n\n", 1)[0] == bulk.body.rsplit("\n\n", 1)[0]
    assert manual.subject == bulk.subject
    assert manual.footer == bulk.footer


# ------------------------------------------------------------------ placeholders


def test_a_valid_template_passes() -> None:
    messaging.validate_template(_template())


def test_an_unknown_placeholder_is_refused_by_name() -> None:
    """A mistyped placeholder delivered to 300 inboxes cannot be recalled (D4)."""
    with pytest.raises(messaging.UnknownPlaceholderError) as caught:
        messaging.validate_template(_template(header="Dear {guset_name}"))
    assert caught.value.found == "{guset_name}"
    assert "{guset_name}" in str(caught.value)


def test_an_unknown_placeholder_is_caught_in_every_part() -> None:
    for part in ("subject", "header", "footer"):
        with pytest.raises(messaging.UnknownPlaceholderError):
            messaging.validate_template(_template(**{part: "{event_date}"}))


def test_literal_braces_are_refused_rather_than_passed_through() -> None:
    """Braces in invitation copy are rare enough that refusing beats an escaping convention
    nobody will remember."""
    with pytest.raises(messaging.UnknownPlaceholderError):
        messaging.validate_template(
            _template(body="Dress code: {smart casual}\n\n{invitation_link}")
        )


def test_a_body_without_the_link_placeholder_is_refused() -> None:
    with pytest.raises(messaging.MissingLinkPlaceholderError):
        messaging.validate_template(_template(body="Just turn up on the day."))


def test_the_link_placeholder_elsewhere_does_not_satisfy_the_body_rule() -> None:
    """The link has to be in the message, not tucked into the sign-off the host may clear."""
    with pytest.raises(messaging.MissingLinkPlaceholderError):
        messaging.validate_template(
            _template(body="Please come.", footer="Regards\n{invitation_link}")
        )


# ------------------------------------------------------------------ per-guest rendering


def test_two_guests_render_to_two_names_and_two_links() -> None:
    """The whole point of a template: one authored text, 300 personal messages (D3)."""
    template = _template()
    first = messaging.render_for_guest(
        template, guest_name="Harun Abdullah", invite_url="https://x.test/i/AAA"
    )
    second = messaging.render_for_guest(
        template, guest_name="Nusrat Jahan", invite_url="https://x.test/i/BBB"
    )

    assert "Dear Harun Abdullah" in first.body
    assert "https://x.test/i/AAA" in first.body
    assert "Dear Nusrat Jahan" in second.body
    assert "https://x.test/i/BBB" in second.body
    assert "AAA" not in second.body
    assert "Nusrat" not in first.body


def test_rendering_substitutes_the_subject_too() -> None:
    rendered = messaging.render_for_guest(
        _template(subject="A message for {guest_name}"),
        guest_name="Harun Abdullah",
        invite_url="https://x.test/i/AAA",
    )
    assert rendered.subject == "A message for Harun Abdullah"


def test_rendering_joins_the_parts_exactly_as_a_single_send_does() -> None:
    """`assemble` is shared so a batched message and a manual one are put together the same."""
    template = _template()
    rendered = messaging.render_for_guest(
        template, guest_name="Harun", invite_url="https://x.test/i/AAA"
    )
    assert rendered.body == messaging.assemble(
        "Dear Harun", "Please come.\n\nhttps://x.test/i/AAA", "Regards\nAyesha & Rahim"
    )


def test_an_unmatched_brace_survives_rendering_instead_of_raising() -> None:
    """Substitution is `replace`, not `format`.

    `format` would give every remaining brace meaning and throw at send time, inside a loop
    over 300 guests, long after the admin could have been told about it.
    """
    rendered = messaging.render_for_guest(
        messaging.ComposedTemplate(
            subject="Invited",
            header="Dear {guest_name}",
            body="Bring a dish {\n\n{invitation_link}",
            footer="Regards",
        ),
        guest_name="Harun",
        invite_url="https://x.test/i/AAA",
    )
    assert "Bring a dish {" in rendered.body


# ------------------------------------------------------------------ idempotency keys


def test_one_batch_produces_the_same_key_for_the_same_invitation() -> None:
    """D5: this is what makes a double-clicked Send insert nothing twice."""
    invitation_id, batch_id = uuid.uuid4(), uuid.uuid4()
    first = messaging.batch_idempotency_key(invitation_id, batch_id)
    second = messaging.batch_idempotency_key(invitation_id, batch_id)
    assert first == second
    assert str(invitation_id) in first
    assert str(batch_id) in first


def test_two_batches_produce_different_keys_for_the_same_invitation() -> None:
    """D5: a deliberate resend is delivered rather than silently discarded."""
    invitation_id = uuid.uuid4()
    assert messaging.batch_idempotency_key(
        invitation_id, uuid.uuid4()
    ) != messaging.batch_idempotency_key(invitation_id, uuid.uuid4())


def test_the_batch_key_cannot_collide_with_a_scheduled_one() -> None:
    """The scheduled form is `invitation:schedule:channel`; a batch key is namespaced so a
    batch id could never be read as a schedule id."""
    invitation_id, other_id = uuid.uuid4(), uuid.uuid4()
    assert messaging.batch_idempotency_key(
        invitation_id, other_id, Channel.EMAIL
    ) != messaging.idempotency_key(invitation_id, other_id, Channel.EMAIL)


# ------------------------------------------------------------------ suppression codes


def test_suppression_reports_a_code_and_a_sentence_for_each_state() -> None:
    guest = Guest(
        id=uuid.uuid4(),
        wedding_id=uuid.uuid4(),
        event_id=uuid.uuid4(),
        full_name="Harun",
        email="guest@example.test",
    )
    assert messaging.suppression(guest) is None

    guest.email = None
    found = messaging.suppression(guest)
    assert found is not None
    assert found.code == messaging.BLOCK_CODE_NO_EMAIL
    assert found.reason == messaging.BLOCK_NO_EMAIL

    guest.email = "guest@example.test"
    guest.email_invalid = True
    found = messaging.suppression(guest)
    assert found is not None and found.code == messaging.BLOCK_CODE_EMAIL_INVALID

    # Consent outranks everything, exactly as the sentence form already promised.
    guest.do_not_contact = True
    found = messaging.suppression(guest)
    assert found is not None and found.code == messaging.BLOCK_CODE_OPTED_OUT


def test_the_sentence_form_still_agrees_with_the_code_form() -> None:
    """`block_reason` delegates, so the compose panel and the batch cannot disagree about
    whether a guest is sendable."""
    guest = Guest(
        id=uuid.uuid4(),
        wedding_id=uuid.uuid4(),
        event_id=uuid.uuid4(),
        full_name="Harun",
        email=None,
    )
    found = messaging.suppression(guest)
    assert found is not None
    assert messaging.block_reason(guest) == found.reason
