"""INDL-54 — Email Notification Dispatch Engine tests.

Covers the dispatch engine (EmailDispatchService) and the trigger-catalog
endpoint against the PRD Acceptance Criteria and Test Scenarios:

  AC-02 / T-01  edited template content is used
  AC-03 / T-03  inactive template → no send, audited email.skipped_inactive
  AC-04 / T-04  missing row → hardcoded default sent + self-healed row inserted
  AC-05 / T-06  unknown merge field → "—", never raw {{field}}, still sends
  AC-09 / T-07  GET /triggers returns 17, correct is_implemented flags, 401 unauth
  AC-11         exactly one audit row per attempt, recipient masked
  AC-12 / T-08  dispatch failure is fail-open (no raise), audited email.send_failed
  T-09          cross-tenant isolation of the inactive toggle

The physical SMTP send (`_dispatch`) is mocked everywhere so no real email
leaves the process.
"""
from __future__ import annotations

from unittest.mock import AsyncMock, patch
from uuid import uuid4

import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.settings.models.email_template import EmailTemplate
from src.apps.settings.services.email_template_service import EmailTemplateService
from src.apps.site_admin.models.audit_log import AuditLog
from src.core.constants import EmailTriggerKey
from src.core.email_dispatch import _mask_recipient, email_dispatch_service

pytestmark = pytest.mark.asyncio


# ── Fixtures ────────────────────────────────────────────────────────────────

@pytest_asyncio.fixture
async def tenant(db_session: AsyncSession):
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Dispatch Test Cemetery",
        subdomain=f"dispatch-{uuid4().hex[:8]}",
        contact_email=f"info-{uuid4().hex[:6]}@dispatchtest.com",
        contact_phone="(555) 111-2222",
        plan="starter",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    await EmailTemplateService.seed_system_templates(db_session, account.id)
    await db_session.flush()
    return account


@pytest_asyncio.fixture
async def other_tenant(db_session: AsyncSession):
    from src.apps.tenants.models.account import Account

    account = Account(
        organization_name="Other Dispatch Cemetery",
        subdomain=f"dispatch-other-{uuid4().hex[:8]}",
        contact_email=f"info-{uuid4().hex[:6]}@otherdispatch.com",
        plan="starter",
        status="active",
    )
    db_session.add(account)
    await db_session.flush()
    await EmailTemplateService.seed_system_templates(db_session, account.id)
    await db_session.flush()
    return account


async def _audit_count(db, tenant_id, action: str | None = None) -> int:
    stmt = select(func.count(AuditLog.id)).where(
        AuditLog.entity_type == "email_dispatch"
    )
    if tenant_id is not None:
        stmt = stmt.where(AuditLog.tenant_id == tenant_id)
    if action:
        stmt = stmt.where(AuditLog.action == action)
    return (await db.execute(stmt)).scalar() or 0


async def _get_template(db, tenant_id, key: str) -> EmailTemplate | None:
    return await EmailTemplateService.get_by_trigger_key(db, tenant_id, key)


# ── AC-02 / T-01: edited template content is used ────────────────────────────

async def test_edited_template_content_is_sent(db_session, tenant):
    tpl = await _get_template(db_session, tenant.id, "staff_invitation")
    tpl.subject = "CUSTOM SUBJECT for {{invitee_name}}"
    tpl.body = "Custom body — join {{organization_name}}."
    await db_session.flush()

    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.STAFF_INVITATION,
            tenant_id=tenant.id,
            to="jane@example.com",
            context={"invitee_name": "Jane", "organization_name": "Green Hills"},
        )

    assert result.sent is True
    assert result.template_source == "tenant_system"
    kwargs = mock.call_args.kwargs
    assert kwargs["subject"] == "CUSTOM SUBJECT for Jane"
    assert "join Green Hills" in kwargs["body_text"]


# ── AC-03 / T-03: inactive template → skip, no send, audited ─────────────────

async def test_inactive_template_is_skipped_not_sent(db_session, tenant):
    tpl = await _get_template(db_session, tenant.id, "invoice_overdue_reminder")
    tpl.status = "inactive"
    await db_session.flush()

    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.INVOICE_OVERDUE_REMINDER,
            tenant_id=tenant.id,
            to="payer@example.com",
            context={"purchaser_name": "Pat"},
        )

    assert result.sent is False
    assert result.skipped_reason == "template_inactive"
    mock.assert_not_called()
    assert await _audit_count(db_session, tenant.id, "email.skipped_inactive") == 1


# ── AC-04 / T-04: missing row → default sent + self-heal insert ──────────────

async def test_missing_row_uses_default_and_self_heals(db_session, tenant):
    # Delete the seeded staff_invitation row entirely for this tenant.
    tpl = await _get_template(db_session, tenant.id, "staff_invitation")
    await db_session.delete(tpl)
    await db_session.flush()
    assert await _get_template(db_session, tenant.id, "staff_invitation") is None

    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.STAFF_INVITATION,
            tenant_id=tenant.id,
            to="new@example.com",
            context={"invitee_name": "New", "organization_name": "GH"},
        )

    assert result.sent is True
    assert result.template_source == "hardcoded_default"
    mock.assert_called_once()
    # Self-heal: a fresh active, is_system row now exists.
    healed = await _get_template(db_session, tenant.id, "staff_invitation")
    assert healed is not None
    assert healed.is_system is True
    assert healed.status == "active"
    assert await _audit_count(db_session, tenant.id, "email.sent_default_fallback") == 1


# ── AC-05 / T-06: unknown merge field → "—", never raw {{field}} ─────────────

async def test_missing_merge_field_renders_dash(db_session, tenant):
    tpl = await _get_template(db_session, tenant.id, "invoice_payment_received")
    tpl.body = "Receipt for {{purchaser_name}} — ref {{nonexistent_field}}."
    await db_session.flush()

    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.INVOICE_PAYMENT_RECEIVED,
            tenant_id=tenant.id,
            to="payer@example.com",
            context={"purchaser_name": "Sam"},
        )

    assert result.sent is True
    body = mock.call_args.kwargs["body_text"]
    assert "{{nonexistent_field}}" not in body
    assert "—" in body
    assert "Sam" in body


def test_render_helper_flags_missing_fields():
    rendered, missing = email_dispatch_service._render(
        "Hi {{name}}, ref {{missing}}", {"name": "Al"}
    )
    assert rendered == "Hi Al, ref —"
    assert missing == {"missing"}


# ── AC-11: exactly one audit row per attempt, recipient masked ───────────────

async def test_one_audit_row_per_send_with_masked_recipient(db_session, tenant):
    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()):
        await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.NEWS_NOTIFICATION,
            tenant_id=tenant.id,
            to="subscriber@example.com",
            context={"post_title": "Hello"},
        )
    assert await _audit_count(db_session, tenant.id, "email.sent") == 1
    row = (
        await db_session.execute(
            select(AuditLog).where(
                AuditLog.entity_type == "email_dispatch",
                AuditLog.tenant_id == tenant.id,
                AuditLog.action == "email.sent",
            )
        )
    ).scalar_one()
    assert row.new_value["recipient"] == "s***@example.com"
    assert "subscriber@example.com" not in str(row.new_value)


def test_mask_recipient_helper():
    assert _mask_recipient("john.doe@example.com") == "j***@example.com"
    assert _mask_recipient("") == "***"
    assert _mask_recipient("not-an-email") == "***"


# ── AC-12 / T-08: fail-open — dispatch failure never raises ──────────────────

async def test_dispatch_failure_is_fail_open(db_session, tenant):
    boom = AsyncMock(side_effect=RuntimeError("SMTP unreachable"))
    with patch("src.core.email_dispatch._dispatch", new=boom):
        # Must NOT raise — the calling business transaction must survive.
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.CONTRACT_SIGNED,
            tenant_id=tenant.id,
            to="buyer@example.com",
            context={"purchaser_name": "Bee"},
        )
    assert result.sent is False
    assert await _audit_count(db_session, tenant.id, "email.send_failed") == 1


# ── T-09: cross-tenant isolation of the inactive toggle ──────────────────────

async def test_cross_tenant_toggle_isolation(db_session, tenant, other_tenant):
    a_tpl = await _get_template(db_session, tenant.id, "contract_signed")
    a_tpl.status = "inactive"
    await db_session.flush()

    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        res_a = await email_dispatch_service.send(
            db_session, trigger_key=EmailTriggerKey.CONTRACT_SIGNED,
            tenant_id=tenant.id, to="a@example.com", context={},
        )
        res_b = await email_dispatch_service.send(
            db_session, trigger_key=EmailTriggerKey.CONTRACT_SIGNED,
            tenant_id=other_tenant.id, to="b@example.com", context={},
        )

    assert res_a.sent is False and res_a.skipped_reason == "template_inactive"
    assert res_b.sent is True
    mock.assert_called_once()  # only tenant B actually sent


# ── Platform-level trigger (no owning tenant) uses default, audits null tenant ─

async def test_platform_trigger_without_tenant(db_session):
    with patch("src.core.email_dispatch._dispatch", new=AsyncMock()) as mock:
        result = await email_dispatch_service.send(
            db_session,
            trigger_key=EmailTriggerKey.CONTACT_ENQUIRY_REPLY,
            tenant_id=None,
            to="lead@example.com",
            context={"contact_name": "Lee", "reply_message": "Thanks!",
                     "cemetery_name": "INDELIS"},
        )
    assert result.sent is True
    assert result.template_source == "hardcoded_default"
    assert "Thanks!" in mock.call_args.kwargs["body_text"]
    # tenant_id=None always renders the default, so the outcome is the
    # default-fallback action, audited with a null tenant.
    assert await _audit_count(db_session, None, "email.sent_default_fallback") >= 1


# ── AC-09 / T-07: GET /triggers endpoint ─────────────────────────────────────

async def _headers(user, account) -> dict:
    from src.core.security import create_access_token, build_token_payload

    token = create_access_token(build_token_payload(user, account))
    return {"Authorization": f"Bearer {token}", "X-Tenant-ID": str(account.id)}


async def test_triggers_endpoint_returns_full_catalog(client: AsyncClient, db_session, tenant):
    from src.apps.auth.models.user import User
    from src.core.security import hash_password

    user = User(
        tenant_id=tenant.id, email=f"vo-{uuid4().hex[:6]}@dispatchtest.com",
        password_hash=hash_password("TestPassword123!"), first_name="View",
        last_name="Only", role="view_only", status="active",
    )
    db_session.add(user)
    await db_session.flush()

    resp = await client.get(
        "/api/v1/settings/email-templates/triggers",
        headers=await _headers(user, tenant),
    )
    assert resp.status_code == 200
    items = resp.json()["data"]["items"]
    assert len(items) == 17
    not_impl = {i["trigger_key"] for i in items if not i["is_implemented"]}
    assert not_impl == {"field_crew_briefing", "tribute_moderated", "deed_transferred"}
    assert all(i["template_id"] for i in items)


async def test_triggers_endpoint_requires_auth(client: AsyncClient, tenant):
    resp = await client.get(
        "/api/v1/settings/email-templates/triggers",
        headers={"X-Tenant-ID": str(tenant.id)},
    )
    assert resp.status_code == 401
