"""Composing and rendering a one-guest invitation email (add-guest-invitation-send D2-D4, D12).

Pure unit tests: `compose_manual` and `render_stored` take an already-loaded invitation and
touch no database, which is deliberate — the resolution order they implement is the same one
the invitation page uses, and it should be checkable without Postgres running.

What these guard is drift. The sentence a guest reads resolves event → wedding → built-in
default, keyed by locale *and* invitation type, and never falls back across locales. Every one
of those steps has a way of quietly regressing into "the admin previewed one thing and the
guest received another", which is the failure this whole path exists to prevent.
"""

import uuid
from datetime import UTC, datetime, timedelta

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


def _invitation(
    *,
    locale: Locale = Locale.EN,
    invitation_type: InvitationType = InvitationType.SINGLE,
    event_messages: dict[str, dict[str, str]] | None = None,
    wedding_messages: dict[str, dict[str, str]] | None = None,
    email: str | None = "guest@example.test",
) -> Invitation:
    """An invitation graph in memory — nothing is flushed, so no database is involved."""
    wedding = Wedding(
        id=uuid.uuid4(),
        bride_name="Ayesha",
        groom_name="Rahim",
        slug="ayesha-rahim",
        timezone="Asia/Dhaka",
        invitation_messages=wedding_messages or {},
    )
    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=event_messages or {},
    )
    event.wedding = wedding
    guest = Guest(
        id=uuid.uuid4(),
        wedding_id=wedding.id,
        event_id=event.id,
        full_name="Harun Abdullah",
        email=email,
        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
    return invitation


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


def test_a_single_guest_gets_the_single_message() -> None:
    composed = messaging.compose_manual(
        _invitation(
            invitation_type=InvitationType.SINGLE,
            event_messages={"en": {"single": "Come and eat", "family": "Bring everyone"}},
        )
    )
    assert "Come and eat" in composed.body
    assert "Bring everyone" not in composed.body


def test_a_family_guest_gets_the_family_message() -> None:
    composed = messaging.compose_manual(
        _invitation(
            invitation_type=InvitationType.FAMILY,
            event_messages={"en": {"single": "Come and eat", "family": "Bring everyone"}},
        )
    )
    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:
    """The whole point of a per-event override — an admin who set one must see it here."""
    composed = messaging.compose_manual(
        _invitation(
            event_messages={"en": {"single": "Walima only"}},
            wedding_messages={"en": {"single": "Wedding wide"}},
        )
    )
    assert "Walima only" in composed.body
    assert "Wedding wide" not in composed.body


def test_the_wedding_wide_message_is_used_when_the_event_stores_nothing() -> None:
    composed = messaging.compose_manual(
        _invitation(event_messages={}, wedding_messages={"en": {"single": "Wedding wide"}})
    )
    assert "Wedding wide" in composed.body


def test_the_built_in_default_is_used_when_neither_stores_anything() -> None:
    """A composed message is never empty — an admin must never be handed a blank body."""
    composed = messaging.compose_manual(_invitation())
    assert "You are cordially invited" in composed.body


def test_a_bangla_guest_gets_bangla_chrome_and_no_english_leaks_in() -> None:
    """An English override on the event must not reach a Bangla reader (greeting D10).

    The resolver refuses to fall back across locales on purpose; composing has to inherit
    that, or customising English would silently rewrite every Bangla invitation.
    """
    composed = messaging.compose_manual(
        _invitation(locale=Locale.BN, event_messages={"en": {"single": "English only"}})
    )
    assert composed.header.startswith("প্রিয়")
    assert "শুভেচ্ছান্তে" in composed.footer
    assert "English only" not in composed.body
    assert "আপনাকে সাদর আমন্ত্রণ" in composed.body
    assert composed.event_title == "ওয়ালিমা"


def test_every_composition_carries_the_guests_invite_link() -> None:
    composed = messaging.compose_manual(_invitation())
    assert composed.invite_url.endswith("/i/YYsasBj5-L0UcOfwSCbIqA")
    assert composed.invite_url in composed.body


def test_the_header_names_the_guest_and_the_footer_names_the_couple() -> None:
    composed = messaging.compose_manual(_invitation())
    assert composed.header == "Dear Harun Abdullah"
    assert composed.footer == "Regards\nAyesha & Rahim"


def test_the_recipient_address_comes_from_the_guest() -> None:
    assert messaging.compose_manual(_invitation()).to_email == "guest@example.test"
    assert messaging.compose_manual(_invitation(email=None)).to_email is None


# ------------------------------------------------------------------ assembly


def test_assemble_joins_the_parts_in_order_with_blank_lines() -> None:
    assert messaging.assemble("Dear X", "Come", "Regards\nY") == "Dear X\n\nCome\n\nRegards\nY"


def test_assemble_drops_a_part_the_admin_emptied() -> None:
    """An admin who clears the footer gets no trailing blank lines, not an empty paragraph."""
    assert messaging.assemble("Dear X", "Come", "   ") == "Dear X\n\nCome"


# ------------------------------------------------------------------ blocking


def test_block_reason_names_each_suppression() -> None:
    guest = _invitation().guest
    assert messaging.block_reason(guest) is None

    guest.email = None
    assert messaging.block_reason(guest) == messaging.BLOCK_NO_EMAIL

    guest.email = "guest@example.test"
    guest.email_invalid = True
    assert messaging.block_reason(guest) == messaging.BLOCK_EMAIL_INVALID

    # Consent outranks everything: an opted-out guest reads as opted out even if their
    # address would also have been rejected for another reason.
    guest.do_not_contact = True
    assert messaging.block_reason(guest) == messaging.BLOCK_OPTED_OUT


def test_a_missing_guest_is_blocked_rather_than_crashing() -> None:
    assert messaging.block_reason(None) == messaging.BLOCK_NO_EMAIL


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


def test_two_manual_sends_to_one_invitation_get_different_keys() -> None:
    """D2: the shared `direct` key would make the second send a silent no-op."""
    invitation_id = uuid.uuid4()
    first = messaging.manual_idempotency_key(invitation_id)
    second = messaging.manual_idempotency_key(invitation_id)
    assert first != second
    assert str(invitation_id) in first


def test_the_scheduled_key_is_unchanged() -> None:
    """Waves keep deduplicating — repeatability is the manual path's property alone."""
    invitation_id, schedule_id = uuid.uuid4(), uuid.uuid4()
    key = messaging.idempotency_key(invitation_id, schedule_id, Channel.EMAIL)
    assert key == f"{invitation_id}:{schedule_id}:{Channel.EMAIL}"
    assert key == messaging.idempotency_key(invitation_id, schedule_id, Channel.EMAIL)


# ------------------------------------------------------------------ stored rendering


def _job(body: str, subject: str = "You're invited") -> MessageJob:
    return MessageJob(
        id=uuid.uuid4(),
        invitation_id=uuid.uuid4(),
        channel=Channel.EMAIL,
        scheduled_for=datetime.now(UTC),
        idempotency_key="k",
        subject=subject,
        body_text=body,
    )


def test_a_stored_body_is_sent_as_the_admin_wrote_it() -> None:
    invitation = _invitation()
    rendered = messaging.render_stored(_job("Dear Harun\n\nPlease come."), invitation)
    assert "Please come." in rendered.text
    assert rendered.subject == "You're invited"


def test_the_unsubscribe_line_is_added_to_an_edited_body_that_omits_it() -> None:
    """D12: the opt-out is the one line a host editing their own copy may not remove."""
    invitation = _invitation()
    rendered = messaging.render_stored(_job("Just come, no link needed."), invitation)
    assert f"/u/{invitation.token}" in rendered.text
    assert "Walima" in rendered.text  # the footer names the event it covers


def test_a_stored_body_gets_the_same_html_wrapper_as_a_template_body() -> None:
    rendered = messaging.render_stored(_job("Dear Harun\n\nPlease come."), _invitation())
    assert rendered.html.startswith("<!doctype html>")
    assert "<br>" in rendered.html


def test_the_unsubscribe_footer_of_a_bangla_guest_is_bangla() -> None:
    invitation = _invitation(locale=Locale.BN)
    rendered = messaging.render_stored(_job("আসুন"), invitation)
    assert "ইমেইল বন্ধ করতে" in rendered.text
