"""Covers app/crud/notifications.py — a workflow step's configured webhook/
email notifications must fire without ever making a real network call or
sending a real email in tests, and a dispatch failure must never raise (a
notification failure must not block the workflow transition that triggered
it). Delivery is a queued job (app/core/jobs.py), run here with
jobs.run_due. httpx and smtplib are mocked; a real throwaway Connector row
is used so the DB-side connector lookup is exercised for real."""

import smtplib
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

from sqlalchemy import select, text

from app.core import jobs
from tests.conftest import AsyncSessionLocal
from app.crud.notifications import DELIVER_JOB, _send_email, _send_webhook, dispatch_step_notifications
from app.models.notification import Notification
from app.models.reporting import Connector
from app.models.workflow import Workflow, WorkflowInstance


def _fake_workflow(step: dict) -> SimpleNamespace:
    return SimpleNamespace(name="Fake Workflow", step_definitions=[step])


async def _make_real_workflow_and_instance(db, tenant_id: uuid.UUID, step: dict):
    """dispatch_step_notifications writes a Notification row with a real FK
    to workflow_instances, so the two integration tests below need actual
    rows rather than SimpleNamespace stand-ins (unlike the pure dispatch
    helpers above, which never touch the DB)."""
    workflow = Workflow(tenant_id=tenant_id, name="Pytest Workflow", step_definitions=[step], status="published")
    db.add(workflow)
    await db.flush()

    instance = WorkflowInstance(tenant_id=tenant_id, workflow_id=workflow.workflow_id, workflow_version=workflow.version, current_step=step["key"])
    db.add(instance)
    await db.flush()
    return workflow, instance


def _mock_http(mock_client_cls, **request):
    mock_client = AsyncMock()
    mock_client.request = AsyncMock(**request)
    mock_client_cls.return_value.__aenter__.return_value = mock_client
    return mock_client


async def test_send_webhook_success():
    with patch("app.crud.notifications.httpx.AsyncClient") as mock_client_cls:
        mock_client = _mock_http(mock_client_cls, return_value=SimpleNamespace(status_code=200))
        note, retry = await _send_webhook({"url": "https://example.com/hook", "method": "POST"}, {"event": "on_enter"})
    assert "webhook sent, HTTP 200" in note and not retry
    mock_client.request.assert_awaited_once()


async def test_send_webhook_no_url_configured_skips_without_error():
    note, retry = await _send_webhook({}, {})
    assert "not sent" in note and not retry


async def test_send_webhook_network_failure_and_5xx_are_worth_retrying_4xx_isnt():
    import httpx

    with patch("app.crud.notifications.httpx.AsyncClient") as mock_client_cls:
        _mock_http(mock_client_cls, side_effect=httpx.ConnectError("refused"))
        note, retry = await _send_webhook({"url": "https://example.com/hook"}, {})
    assert "failed" in note and retry
    for code, worth in ((503, True), (429, True), (404, False)):
        with patch("app.crud.notifications.httpx.AsyncClient") as mock_client_cls:
            _mock_http(mock_client_cls, return_value=SimpleNamespace(status_code=code))
            note, retry = await _send_webhook({"url": "https://example.com/hook"}, {})
        assert f"HTTP {code}" in note and retry is worth


async def test_send_email_success_uses_smtp_without_real_network():
    config = {"host": "smtp.example.com", "from": "noreply@example.com", "port": 587, "username": "u", "password": "p"}
    with patch("app.crud.notifications.smtplib.SMTP") as mock_smtp_cls:
        mock_smtp = MagicMock()
        mock_smtp_cls.return_value.__enter__.return_value = mock_smtp
        note, retry = await _send_email(config, "recipient@example.com", "Subject", "Body")

    assert "emailed to recipient@example.com" in note and not retry
    mock_smtp.starttls.assert_called_once()
    mock_smtp.login.assert_called_once_with("u", "p")
    mock_smtp.sendmail.assert_called_once()


async def test_send_email_temporary_failures_are_retried_permanent_ones_arent():
    config = {"host": "smtp.example.com", "from": "noreply@example.com"}
    with patch("app.crud.notifications.smtplib.SMTP", side_effect=smtplib.SMTPConnectError(421, "down")):
        note, retry = await _send_email(config, "recipient@example.com", "Subject", "Body")
    assert "failed" in note and retry
    with patch("app.crud.notifications.smtplib.SMTP", side_effect=smtplib.SMTPResponseException(550, "no such mailbox")):
        note, retry = await _send_email(config, "recipient@example.com", "Subject", "Body")
    assert "failed" in note and not retry
    note, retry = await _send_email({}, "recipient@example.com", "Subject", "Body")
    assert "not sent" in note and not retry


async def test_dispatch_step_notifications_writes_audit_row_and_fires_webhook(test_tenant):
    tenant_id = test_tenant["tenant_id"]
    async with AsyncSessionLocal() as db:
        async with db.begin():
            connector = Connector(
                tenant_id=tenant_id,
                name="Pytest Webhook",
                type="webhook",
                configuration={"url": "https://example.com/hook"},
                status="enabled",
            )
            db.add(connector)
            await db.flush()
            connector_id = connector.id

    step = {
        "key": "step1",
        "label": "Step One",
        "notifications": [{"connector_id": str(connector_id), "trigger": "on_enter", "message": "hello"}],
    }

    mock_response = SimpleNamespace(status_code=200)
    with patch("app.crud.notifications.httpx.AsyncClient") as mock_client_cls:
        mock_client = _mock_http(mock_client_cls, return_value=mock_response)
        async with AsyncSessionLocal() as db:
            async with db.begin():
                workflow, instance = await _make_real_workflow_and_instance(db, tenant_id, step)
                await dispatch_step_notifications(db, tenant_id, workflow, "step1", "on_enter", instance)
        # Nothing is sent inside the transition — the row says it's queued.
        mock_client.request.assert_not_awaited()
        async with AsyncSessionLocal() as db:
            [row] = (await db.execute(select(Notification).where(Notification.tenant_id == tenant_id))).scalars().all()
            assert row.message == "hello (webhook queued)"

        assert await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id) == 1
    assert mock_client.request.await_args.kwargs["json"]["event"] == "on_enter"

    async with AsyncSessionLocal() as db:
        rows = (await db.execute(select(Notification).where(Notification.tenant_id == tenant_id))).scalars().all()
        assert len(rows) == 1
        assert "webhook sent" in rows[0].message


async def test_dispatch_step_notifications_disabled_connector_is_skipped(test_tenant):
    tenant_id = test_tenant["tenant_id"]
    async with AsyncSessionLocal() as db:
        async with db.begin():
            connector = Connector(
                tenant_id=tenant_id,
                name="Pytest Disabled Webhook",
                type="webhook",
                configuration={"url": "https://example.com/hook"},
                status="disabled",
            )
            db.add(connector)
            await db.flush()
            connector_id = connector.id

    step = {
        "key": "step1",
        "notifications": [{"connector_id": str(connector_id), "trigger": "on_enter", "message": "hello"}],
    }

    async with AsyncSessionLocal() as db:
        async with db.begin():
            workflow, instance = await _make_real_workflow_and_instance(db, tenant_id, step)
            await dispatch_step_notifications(db, tenant_id, workflow, "step1", "on_enter", instance)

    async with AsyncSessionLocal() as db:
        rows = (await db.execute(select(Notification).where(Notification.tenant_id == tenant_id))).scalars().all()
        assert len(rows) == 0

async def test_webhook_url_can_use_a_secret_and_the_note_never_shows_it(test_tenant):
    import httpx

    from app.core.crypto import encrypt_secret
    from app.crud.secrets import MASK
    from app.models.secret import Secret

    tenant_id = test_tenant["tenant_id"]
    token = "hook_token_5f4e3d2c"
    async with AsyncSessionLocal() as db:
        async with db.begin():
            db.add(Secret(tenant_id=tenant_id, name="HOOK_TOKEN", value_encrypted=encrypt_secret(token)))
            connector = Connector(tenant_id=tenant_id, name="Secret Webhook", type="webhook", configuration={"url": "https://example.com/hook?token={{secret.HOOK_TOKEN}}"}, status="enabled")
            db.add(connector)
            await db.flush()
            connector_id = connector.id

    step = {"key": "step1", "label": "Step One", "notifications": [{"connector_id": str(connector_id), "trigger": "on_enter", "message": "hello"}]}
    async with AsyncSessionLocal() as db:
        async with db.begin():
            workflow, instance = await _make_real_workflow_and_instance(db, tenant_id, step)
            await dispatch_step_notifications(db, tenant_id, workflow, "step1", "on_enter", instance)
    # The job holds no secret — the connector's settings are read when it runs.
    async with AsyncSessionLocal() as db:
        payload = (await db.execute(select(text("payload")).select_from(text("jobs")).where(text("tenant_id = :t")), {"t": tenant_id})).scalar_one()
    assert token not in str(payload)
    with patch("app.crud.notifications.httpx.AsyncClient") as mock_client_cls:
        # The failure echoes the URL — and with it the token.
        mock_client = _mock_http(mock_client_cls, side_effect=httpx.ConnectError(f"refused https://example.com/hook?token={token}"))
        await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id)
    called_url = mock_client.request.await_args.args[1]

    assert called_url == f"https://example.com/hook?token={token}"
    async with AsyncSessionLocal() as db:
        message = (await db.execute(select(Notification.message).where(Notification.tenant_id == tenant_id))).scalar_one()
        stored_config = (await db.execute(select(Connector.configuration).where(Connector.id == connector_id))).scalar_one()
    assert token not in message and MASK in message
    assert stored_config["url"].endswith("{{secret.HOOK_TOKEN}}")


async def _email_connector(tenant_id) -> uuid.UUID:
    async with AsyncSessionLocal() as db:
        async with db.begin():
            connector = Connector(
                tenant_id=tenant_id, name="Pytest SMTP", type="email_smtp",
                configuration={"host": "smtp.example.com", "from": "noreply@example.com"}, status="enabled",
            )
            db.add(connector)
            await db.flush()
            return connector.id


async def _job(tenant_id):
    async with AsyncSessionLocal() as db:
        return (await db.execute(text("SELECT status, attempts, last_error FROM jobs WHERE tenant_id = :t AND kind = :k"), {"t": tenant_id, "k": DELIVER_JOB})).one()


async def _make_due(tenant_id):
    async with AsyncSessionLocal() as db:
        async with db.begin():
            await db.execute(text("UPDATE jobs SET run_after = now() WHERE tenant_id = :t"), {"t": tenant_id})


async def test_a_failed_email_is_retried_and_each_address_sent_once(test_tenant):
    tenant_id = test_tenant["tenant_id"]
    connector_id = await _email_connector(tenant_id)
    users = [uuid.UUID(str(test_tenant["admin_id"]))]
    step = {"key": "step1", "label": "Step One", "notifications": [{"connector_id": str(connector_id), "trigger": "on_enter", "message": "hello", "recipient_user_ids": [str(u) for u in users]}]}
    async with AsyncSessionLocal() as db:
        async with db.begin():
            workflow, instance = await _make_real_workflow_and_instance(db, tenant_id, step)
            await dispatch_step_notifications(db, tenant_id, workflow, "step1", "on_enter", instance)

    sent = []

    def flaky(config, to, subject, body):
        if not sent:
            sent.append(None)
            raise smtplib.SMTPServerDisconnected("connection dropped")
        sent.append(to)

    with patch("app.crud.notifications._send_email_sync", side_effect=flaky):
        await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id)
        status, attempts, error = await _job(tenant_id)
        assert (status, attempts) == ("queued", 1) and "connection dropped" in error
        async with AsyncSessionLocal() as db:
            message = (await db.execute(select(Notification.message).where(Notification.tenant_id == tenant_id))).scalar_one()
        assert "will retry" in message

        # The retry waits its backoff; then the address gets its email.
        assert await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id) == 0
        await _make_due(tenant_id)
        await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id)

    assert len(sent) == 2 and sent[1] is not None
    assert (await _job(tenant_id))[:2] == ("done", 2)
    async with AsyncSessionLocal() as db:
        message = (await db.execute(select(Notification.message).where(Notification.tenant_id == tenant_id))).scalar_one()
    assert message.startswith("hello (emailed to ") and "retry" not in message


async def test_delivery_gives_up_after_its_attempts(test_tenant, monkeypatch):
    tenant_id = test_tenant["tenant_id"]
    connector_id = await _email_connector(tenant_id)
    step = {"key": "step1", "notifications": [{"connector_id": str(connector_id), "trigger": "on_enter", "message": "hello", "recipient_user_ids": [str(test_tenant["admin_id"])]}]}
    async with AsyncSessionLocal() as db:
        async with db.begin():
            workflow, instance = await _make_real_workflow_and_instance(db, tenant_id, step)
            await dispatch_step_notifications(db, tenant_id, workflow, "step1", "on_enter", instance)
            await db.execute(text("UPDATE jobs SET max_attempts = 2 WHERE tenant_id = :t"), {"t": tenant_id})

    with patch("app.crud.notifications._send_email_sync", side_effect=OSError("unreachable")):
        await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id)
        await _make_due(tenant_id)
        await jobs.run_due([DELIVER_JOB], tenant_id=tenant_id)

    status, attempts, error = await _job(tenant_id)
    assert (status, attempts) == ("failed", 2) and "unreachable" in error
    async with AsyncSessionLocal() as db:
        message = (await db.execute(select(Notification.message).where(Notification.tenant_id == tenant_id))).scalar_one()
    assert "gave up after 2 attempts" in message
