"""Bug-fix coverage for the ``contract_signed`` confirmation email's
"Balance due" line (arq_app.py::send_contract_email + migration 0083).

Bug: some tenants' seeded ``contract_signed`` template body still contained
a dead ``{{balance_due}}`` merge field (never populated by
``send_contract_email``, which only ever set ``context["payment_summary"]``)
so those tenants' purchasers saw "Balance due: —" regardless of the actual
amount. Two fixes:

  1. Migration 0083 converges any exact-match stale ("Variant A") template
     body onto the current body (which uses ``{{payment_summary}}``).
  2. ``send_contract_email`` now ALSO populates ``context["balance_due"]``
     as a defensive fallback for tenant-customized templates that still
     reference the old field — always the amount due NOW, never the full
     contract total.

These tests exercise ``send_contract_email`` directly (it opens its own
``AsyncSessionLocal``, so — mirroring the established pattern in
``tests/test_scheduling_notification_jobs.py`` — we monkeypatch that
sessionmaker to the test DB, seed committed rows, and clean up on teardown).
"""
from __future__ import annotations

from decimal import Decimal
from unittest.mock import AsyncMock
from uuid import uuid4

import pytest
import pytest_asyncio
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

import src.core.email_dispatch as email_dispatch_mod
import src.worker.arq_app as arq_app
from src.apps.billing.models.invoice import Invoice
from src.apps.billing.services.invoice_service import InvoiceService
from src.apps.sales.models.contract import Contract
from src.apps.settings.models.email_template import EmailTemplate
from src.apps.settings.services.email_template_service import EmailTemplateService
from src.apps.tenants.models.account import Account
from src.core.config import settings
from src.core.constants import EmailTriggerKey
from src.core.email_dispatch import EmailDispatchResult
from src.worker.arq_app import send_contract_email

# Same test DB the shared conftest fixtures use.
_TEST_DATABASE_URL = settings.DATABASE_URL.replace("/indelis", "/indelis_test")
if not _TEST_DATABASE_URL.startswith("postgresql+psycopg://"):
    _TEST_DATABASE_URL = "postgresql+psycopg://" + _TEST_DATABASE_URL.split("://", 1)[-1]

_engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
_Session = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)


def _sent_result() -> EmailDispatchResult:
    return EmailDispatchResult(
        sent=True, trigger_key=EmailTriggerKey.CONTRACT_SIGNED.value,
        template_source="tenant_system",
    )


@pytest_asyncio.fixture
async def seed():
    """Factory: commits an Account (+ seeded system templates) + Contract,
    optionally creating its invoices via the real InvoiceService (so the
    real deposit/remainder rounding logic is exercised), and cleans up.
    """
    tenant_ids: list = []

    async def _make(
        *,
        payment_plan_type: str = "deposit_50",
        total_amount: str = "10000.00",
        purchaser_email: str | None = "buyer@example.ca",
        create_invoices: bool = True,
    ) -> dict:
        async with _Session() as s:
            account = Account(
                organization_name="Balance Due Test Cemetery",
                subdomain=f"balance-due-{uuid4().hex[:10]}",
                contact_email="office@balanceduetest.example.com",
                contact_phone="(555) 222-3333",
                plan="starter",
                status="active",
            )
            s.add(account)
            await s.flush()
            await EmailTemplateService.seed_system_templates(s, account.id)

            contract = Contract(
                tenant_id=account.id,
                contract_number=f"C-{uuid4().hex[:8].upper()}",
                contract_type="pre_need",
                status="signed",
                total_amount=Decimal(total_amount),
                purchaser_name="Mary McLeod",
                purchaser_email=purchaser_email,
                payment_plan_type=payment_plan_type,
            )
            s.add(contract)
            await s.flush()

            invoices = []
            if create_invoices:
                invoices = await InvoiceService.create_contract_invoices(
                    s,
                    tenant_id=account.id,
                    contract_id=contract.id,
                    payment_plan_type=payment_plan_type,
                    total_amount=Decimal(total_amount),
                    purchaser_name=contract.purchaser_name,
                    purchaser_email=contract.purchaser_email,
                )

            await s.commit()
            tenant_ids.append(account.id)
            return {
                "tenant_id": account.id,
                "contract_id": contract.id,
                "invoices": [(inv.id, inv.balance_due, inv.due_date) for inv in invoices],
            }

    yield _make

    async with _Session() as s:
        for tid in tenant_ids:
            await s.execute(delete(Invoice).where(Invoice.tenant_id == tid))
            await s.execute(delete(Contract).where(Contract.tenant_id == tid))
            await s.execute(delete(EmailTemplate).where(EmailTemplate.tenant_id == tid))
            await s.execute(delete(Account).where(Account.id == tid))
        await s.commit()


@pytest.fixture
def patch_session(monkeypatch):
    """Point the job at the test DB (it uses its own AsyncSessionLocal)."""
    monkeypatch.setattr(arq_app, "AsyncSessionLocal", _Session)


@pytest.fixture
def mock_dispatch_send(monkeypatch):
    """Mock the dispatch-engine `.send()` entry point — captures the exact
    `context` dict `send_contract_email` builds, without going through
    template lookup/rendering."""
    mock = AsyncMock(return_value=_sent_result())
    monkeypatch.setattr(email_dispatch_mod.email_dispatch_service, "send", mock)
    return mock


# ── Core bug-fix assertions: deposit_50 context ─────────────────────────────


@pytest.mark.asyncio
async def test_deposit_50_context_has_due_now_due_later_and_total(
    seed, patch_session, mock_dispatch_send
):
    rec = await seed(payment_plan_type="deposit_50", total_amount="10000.00")

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/abc123"
    )

    assert result["status"] == "sent"
    mock_dispatch_send.assert_awaited_once()
    context = mock_dispatch_send.call_args.kwargs["context"]

    assert context["contract_total"] == "$10,000.00"
    # The single most important assertion for the reported bug: balance_due
    # must be the amount due NOW (the deposit), never the full total.
    assert context["balance_due"] == "$5,000.00"
    assert context["payment_summary"] == (
        "Amount due now: $5,000.00\n"
        "Remaining balance (due at interment): $5,000.00"
    )


@pytest.mark.asyncio
async def test_full_plan_context_uses_single_balance_due_line(
    seed, patch_session, mock_dispatch_send
):
    rec = await seed(payment_plan_type="full", total_amount="3000.00")

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/xyz789"
    )

    assert result["status"] == "sent"
    context = mock_dispatch_send.call_args.kwargs["context"]

    assert context["contract_total"] == "$3,000.00"
    assert context["balance_due"] == "$3,000.00"
    # Full payment plan: single "Balance due: $X" line, NOT the two-line
    # now/later format — confirms the `if later_invoices: ... else: ...`
    # branch is exercised correctly for the non-split path.
    assert context["payment_summary"] == "Balance due: $3,000.00"
    assert "Amount due now" not in context["payment_summary"]
    assert "Remaining balance" not in context["payment_summary"]


@pytest.mark.asyncio
async def test_deposit_50_uneven_split_sums_exactly_to_total(
    seed, patch_session, mock_dispatch_send
):
    """$100.01 doesn't split evenly by 2 — confirm InvoiceService's
    rounding doesn't lose/gain a cent, and the email context reflects the
    real (unequal) invoice amounts, still summing to the contract total."""
    rec = await seed(payment_plan_type="deposit_50", total_amount="100.01")

    inv_amounts = sorted(bal for (_id, bal, _due) in rec["invoices"])
    assert len(inv_amounts) == 2
    assert inv_amounts[0] + inv_amounts[1] == Decimal("100.01")
    # Half of 100.01 quantized to cents is 50.01 (banker's/ROUND_HALF_EVEN
    # default), remainder therefore 50.00 — whichever way it lands, the two
    # legs must differ by at most one cent and sum exactly to the total.
    assert abs(inv_amounts[0] - inv_amounts[1]) <= Decimal("0.01")

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/uneven"
    )
    assert result["status"] == "sent"
    context = mock_dispatch_send.call_args.kwargs["context"]
    assert context["contract_total"] == "$100.01"

    # due_now (the deposit invoice, due today) must equal balance_due and
    # match the deposit leg of the real split, due_later the remainder leg.
    due_now = Decimal(context["balance_due"].replace("$", "").replace(",", ""))
    later_str = context["payment_summary"].split("Remaining balance (due at interment): ")[1]
    due_later = Decimal(later_str.replace("$", "").replace(",", ""))
    assert due_now + due_later == Decimal("100.01")
    assert {due_now, due_later} == set(inv_amounts)


# ── Regression checks (adjacent behavior, exercised by proximity) ──────────


@pytest.mark.asyncio
async def test_no_purchaser_email_skips_cleanly(seed, patch_session, mock_dispatch_send):
    rec = await seed(purchaser_email=None, create_invoices=False)

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/none"
    )

    assert result == {"status": "skipped", "reason": "no_purchaser_email"}
    mock_dispatch_send.assert_not_awaited()


@pytest.mark.asyncio
async def test_missing_merge_field_degrades_to_dash_not_raw_token(
    seed, patch_session, monkeypatch
):
    """Regression check on the rendering pipeline this fix touches by
    proximity: a merge field genuinely absent from context must degrade to
    "—" in the sent body, never leak a raw {{token}} nor crash the send."""
    rec = await seed(payment_plan_type="full", total_amount="500.00")

    dispatch_mock = AsyncMock()
    monkeypatch.setattr(email_dispatch_mod, "_dispatch", dispatch_mock)

    # Corrupt the seeded contract_signed body for this tenant with a field
    # that will never be present in context.
    async with _Session() as s:
        tpl = await EmailTemplateService.get_by_trigger_key(
            s, rec["tenant_id"], "contract_signed"
        )
        tpl.body = tpl.body.replace(
            "{{bank_details_block}}", "{{bank_details_block}}\nRef: {{totally_made_up_field}}"
        )
        await s.commit()

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/missing"
    )

    assert result["status"] == "sent"
    dispatch_mock.assert_awaited_once()
    body_text = dispatch_mock.call_args.kwargs["body_text"]
    assert "{{totally_made_up_field}}" not in body_text
    assert "Ref: —" in body_text


# ── End-to-end rendering: real template lookup + real _render, only the
#    SMTP-level `_dispatch` is mocked — confirms the CUSTOMER-FACING TEXT is
#    unambiguous, not just that the context dict has the right keys.


@pytest.mark.asyncio
async def test_deposit_50_rendered_body_is_unambiguous(seed, patch_session, monkeypatch):
    rec = await seed(payment_plan_type="deposit_50", total_amount="10000.00")

    dispatch_mock = AsyncMock()
    monkeypatch.setattr(email_dispatch_mod, "_dispatch", dispatch_mock)

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/render"
    )

    assert result["status"] == "sent"
    dispatch_mock.assert_awaited_once()
    body_text = dispatch_mock.call_args.kwargs["body_text"]

    assert "Contract total: $10,000.00" in body_text
    assert "Amount due now: $5,000.00" in body_text
    assert "Remaining balance (due at interment): $5,000.00" in body_text
    # The old dead field must never appear literally in a sent email.
    assert "{{balance_due}}" not in body_text
    assert "Balance due: —" not in body_text


@pytest.mark.asyncio
async def test_full_plan_rendered_body_shows_single_balance_line(
    seed, patch_session, monkeypatch
):
    rec = await seed(payment_plan_type="full", total_amount="3000.00")

    dispatch_mock = AsyncMock()
    monkeypatch.setattr(email_dispatch_mod, "_dispatch", dispatch_mock)

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/full"
    )

    assert result["status"] == "sent"
    body_text = dispatch_mock.call_args.kwargs["body_text"]
    assert "Balance due: $3,000.00" in body_text
    assert "Amount due now" not in body_text
    assert "{{balance_due}}" not in body_text


# ── Variant-A (stale/pre-0083) template, still exercised via arq_app's
#    defensive context["balance_due"] fallback ─────────────────────────────


VARIANT_A_BODY = (
    "Dear {{purchaser_name}},\n\n"
    "Thank you for choosing {{cemetery_name}}. This confirms your "
    "{{contract_type}} contract ({{contract_number}}) for {{plot_id}} "
    "in {{section}} has been signed.\n\n"
    "Contract total: {{contract_total}}\n"
    "Balance due: {{balance_due}}\n\n"
    "{{bank_details_block}}\n\n"
    "If you have any questions, contact us at {{cemetery_email}} or "
    "{{cemetery_phone}}.\n\n"
    "Sincerely,\n{{cemetery_name}}"
)


@pytest.mark.asyncio
async def test_variant_a_stale_template_now_renders_real_balance_due(
    seed, patch_session, monkeypatch
):
    """The actual reported bug, reproduced end-to-end: a tenant stuck on the
    pre-0083 Variant A body (references {{balance_due}}, not
    {{payment_summary}}) — WITHOUT running migration 0083 — must still show
    a real dollar amount, not "—", because send_contract_email now also
    populates context["balance_due"] directly (defensive fallback)."""
    rec = await seed(payment_plan_type="deposit_50", total_amount="10000.00")

    async with _Session() as s:
        tpl = await EmailTemplateService.get_by_trigger_key(
            s, rec["tenant_id"], "contract_signed"
        )
        tpl.body = VARIANT_A_BODY
        await s.commit()

    dispatch_mock = AsyncMock()
    monkeypatch.setattr(email_dispatch_mod, "_dispatch", dispatch_mock)

    result = await send_contract_email(
        {}, str(rec["tenant_id"]), str(rec["contract_id"]), "https://pay.example/variant-a"
    )

    assert result["status"] == "sent"
    body_text = dispatch_mock.call_args.kwargs["body_text"]
    # Before the fix this line rendered "Balance due: —".
    assert "Balance due: $5,000.00" in body_text
    assert "Balance due: —" not in body_text
    assert "{{balance_due}}" not in body_text
