"""Dispatches a workflow step's configured notifications (FR-WFD/FR-CNN
integration): each step in Workflow.step_definitions can carry a
`notifications` list of {connector_id, trigger, message, recipient_field}.
Every dispatch writes a durable Notification row (an audit trail visible in
the app header's bell, regardless of connector type), and on top of that:
  - a "webhook" connector fires a real HTTP POST
  - an "email_smtp" connector sends a real SMTP email, to the address found
    in `recipient_field` on the most recent Form submission tied to the
    workflow instance (e.g. a client's email captured on an order form)
  - a "realtime_notification" connector has no further side effect — the
    Notification row *is* the notification
Webhooks and emails are delivered by a job (app/core/jobs.py), queued in
the transition's own transaction: a slow or unreachable server never holds
the transition up, and nothing is sent for a transition that rolled back.
The row says "queued" until the job has tried; then it says what happened.
A network error, a 5xx/429 response or a temporary SMTP error is retried
with backoff (each email address only until it has been sent); a rejection
(4xx, a refused address, missing configuration) isn't. Connector settings
and their {{secret.NAME}} values are read when the job runs — never stored
in the job — and any secret echoed into a note is masked. A connector
that's disabled or deleted is skipped rather than raising — a notification
failure must never block the workflow transition that triggered it.

Recipients: a notification with recipient roles and/or users (internal or
Public Portal) fans out one row per resolved active user, each with its own
read flag, shown only in that user's bell. One with neither keeps the
original behavior — a single tenant-wide row every internal user sees."""

import asyncio
import smtplib
import uuid
from email.mime.text import MIMEText

import httpx
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import jobs
from app.models.identity import Role, User, UserRole
from app.models.notification import Notification
from app.models.reporting import Connector
from app.crud.secrets import Scrubber, fill_templates, load_values, referenced_names
from app.models.workflow import FormSubmission, Workflow, WorkflowInstance


DELIVER_JOB = "notifications.deliver"


def _webhook_body(workflow: Workflow, step: dict, instance: WorkflowInstance, trigger: str, message: str) -> dict:
    return {
        "event": trigger,
        "workflow": workflow.name,
        "workflow_instance_id": str(instance.id),
        "step": step.get("key"),
        "step_label": step.get("label"),
        "message": message,
    }


# Each sender returns (note for the notification row, whether trying again could help).


async def _send_webhook(config: dict, body: dict) -> tuple[str, bool]:
    url = config.get("url")
    if not url:
        return " (webhook not sent — connector has no URL configured)", False
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.request(config.get("method", "POST"), url, json=body)
    except httpx.HTTPError as exc:
        return f" (webhook failed: {exc})", True
    if resp.status_code >= 500 or resp.status_code == 429:
        return f" (webhook failed, HTTP {resp.status_code})", True
    if resp.status_code >= 400:
        return f" (webhook rejected, HTTP {resp.status_code})", False
    return f" (webhook sent, HTTP {resp.status_code})", False


def _send_email_sync(config: dict, to_addr: str, subject: str, body: str) -> None:
    host = config.get("host")
    from_addr = config.get("from") or config.get("username")
    if not host or not from_addr:
        raise ValueError("connector is missing host/from configuration")
    port = int(config.get("port") or 587)
    username, password = config.get("username"), config.get("password")

    msg = MIMEText(body)
    msg["Subject"] = subject
    msg["From"] = from_addr
    msg["To"] = to_addr

    with smtplib.SMTP(host, port, timeout=10) as smtp:
        smtp.starttls()
        if username and password:
            smtp.login(username, password)
        smtp.sendmail(from_addr, [to_addr], msg.as_string())


async def _resolve_recipient(
    db: AsyncSession, tenant_id: uuid.UUID, instance: WorkflowInstance, recipient_field: str | None
) -> str | None:
    if not recipient_field:
        return None
    stmt = (
        select(FormSubmission)
        .where(FormSubmission.tenant_id == tenant_id, FormSubmission.workflow_instance_id == instance.id)
        .order_by(FormSubmission.submitted_at.desc())
        .limit(1)
    )
    submission = (await db.execute(stmt)).scalar_one_or_none()
    if submission is None:
        return None
    value = submission.field_values.get(recipient_field)
    return str(value) if value else None


async def _send_email(config: dict, recipient: str, subject: str, body: str) -> tuple[str, bool]:
    try:
        await asyncio.to_thread(_send_email_sync, config, recipient, subject, body)
    except ValueError as exc:
        return f" (email to {recipient} not sent: {exc})", False
    except smtplib.SMTPRecipientsRefused as exc:
        return f" (email to {recipient} refused: {exc})", False
    except smtplib.SMTPResponseException as exc:
        # 4xx: the server says try later; 5xx: it won't take this message.
        return f" (email to {recipient} failed: {exc})", exc.smtp_code < 500
    except (smtplib.SMTPException, OSError) as exc:
        return f" (email to {recipient} failed: {exc})", True
    return f" (emailed to {recipient})", False


def _as_uuids(values) -> list[uuid.UUID]:
    out: list[uuid.UUID] = []
    for v in values or []:
        try:
            out.append(v if isinstance(v, uuid.UUID) else uuid.UUID(str(v)))
        except ValueError:
            continue
    return out


def is_targeted(role_ids, user_ids) -> bool:
    return bool(role_ids) or bool(user_ids)


async def resolve_recipient_users(
    db: AsyncSession, tenant_id: uuid.UUID, role_ids, user_ids
) -> list[User]:
    """Every active user in this tenant who holds one of role_ids (internal
    or Public Portal scope alike) or is named in user_ids — resolved at
    dispatch time, so a role's membership as of *now* is what counts.
    Unknown/foreign ids are silently dropped rather than raising: a deleted
    user or role must never break the transition that fired this."""
    role_ids, user_ids = _as_uuids(role_ids), _as_uuids(user_ids)
    if not role_ids and not user_ids:
        return []
    conditions = []
    if user_ids:
        conditions.append(User.id.in_(user_ids))
    if role_ids:
        member_ids = (
            select(UserRole.user_id)
            .join(Role, Role.id == UserRole.role_id)
            .where(Role.tenant_id == tenant_id, Role.id.in_(role_ids))
        )
        conditions.append(User.id.in_(member_ids))
    stmt = (
        select(User)
        .where(User.tenant_id == tenant_id, User.status == "active", or_(*conditions))
        .order_by(User.name)
    )
    return list((await db.execute(stmt)).scalars().all())


def _add_rows(db: AsyncSession, recipients: list[User] | None, **fields) -> list[Notification]:
    """One row per recipient, or — when the notification isn't targeted at
    all (recipients is None) — the single tenant-wide broadcast row."""
    rows = [Notification(**fields)] if recipients is None else [Notification(recipient_user_id=user.id, **fields) for user in recipients]
    db.add_all(rows)
    return rows


async def dispatch_step_notifications(
    db: AsyncSession,
    tenant_id: uuid.UUID,
    workflow: Workflow,
    step_key: str | None,
    trigger: str,
    instance: WorkflowInstance,
) -> None:
    if not step_key:
        return
    step = next((s for s in workflow.step_definitions if s.get("key") == step_key), None)
    if not step:
        return

    for n in step.get("notifications") or []:
        if n.get("trigger") != trigger or not n.get("connector_id"):
            continue
        try:
            connector_id = uuid.UUID(str(n["connector_id"]))
        except ValueError:
            continue
        connector = (
            await db.execute(select(Connector).where(Connector.tenant_id == tenant_id, Connector.id == connector_id))
        ).scalar_one_or_none()
        if connector is None or connector.status != "enabled":
            continue
        message = n.get("message") or f'{step.get("label", step_key)} — {trigger.replace("_", " ")}'
        title = f'{workflow.name} · {step.get("label", step_key)}'
        note = ""
        deliveries: list[dict] = []

        role_ids, user_ids = n.get("recipient_role_ids"), n.get("recipient_user_ids")
        recipients = await resolve_recipient_users(db, tenant_id, role_ids, user_ids) if is_targeted(role_ids, user_ids) else None

        if connector.type == "webhook":
            deliveries.append({"to": None, "note": " (webhook queued)", "done": False})
        elif connector.type == "email_smtp":
            addresses: list[str] = []
            field_recipient = await _resolve_recipient(db, tenant_id, instance, n.get("recipient_field"))
            if field_recipient:
                addresses.append(field_recipient)
            for user in recipients or []:
                if user.email and user.email not in addresses:
                    addresses.append(user.email)
            deliveries = [{"to": a, "note": f" (email to {a} queued)", "done": False} for a in addresses]
            if not addresses:
                note = " (no recipient — set a recipient field or recipient roles/users)"

        rows = _add_rows(
            db,
            recipients,
            tenant_id=tenant_id,
            title=title,
            message=(message + note + "".join(d["note"] for d in deliveries))[:1000],
            source="workflow",
            workflow_instance_id=instance.id,
            connector_id=connector.id,
        )
        if deliveries:
            await db.flush()
            await jobs.enqueue(
                db,
                DELIVER_JOB,
                {
                    "connector_id": str(connector.id),
                    "channel": connector.type,
                    "subject": title,
                    "message": message,
                    "webhook_body": _webhook_body(workflow, step, instance, trigger, message),
                    "notification_ids": [str(r.id) for r in rows],
                    "deliveries": deliveries,
                },
                tenant_id=tenant_id,
            )
    await db.flush()


@jobs.handler(DELIVER_JOB, max_attempts=5)
async def _deliver(job: jobs.Job) -> str:
    """Sends what's still unsent, notes the outcome on the notification
    rows, and raises (so the job is retried) while anything is worth
    another try."""
    p = job.payload
    pending = [d for d in p["deliveries"] if not d["done"]]
    async with jobs.tenant_session(job.tenant_id) as db:
        connector = (
            await db.execute(select(Connector).where(Connector.tenant_id == job.tenant_id, Connector.id == uuid.UUID(p["connector_id"])))
        ).scalar_one_or_none()
        if connector is None or connector.status != "enabled":
            config, scrub = None, str
        else:
            # Settings may hold {{secret.NAME}} references; the note is shown
            # to people, so any secret value echoed into it is masked.
            secret_names = referenced_names(connector.configuration or {})
            secret_values = await load_values(db, job.tenant_id, secret_names) if secret_names else {}
            config, scrub = fill_templates(connector.configuration or {}, secret_values), Scrubber(secret_values)

    for d in pending:
        if config is None:
            note, retry = " (not sent — the connector was disabled or deleted)", False
        elif p["channel"] == "webhook":
            note, retry = await _send_webhook(config, p["webhook_body"])
        else:
            note, retry = await _send_email(config, d["to"], p["subject"], p["message"])
        note = scrub(note)
        if retry:
            note = note[:-1] + (f" — gave up after {job.attempt} attempts)" if job.last_attempt else " — will retry)")
        d["note"], d["done"] = note, not retry

    async with jobs.tenant_session(job.tenant_id) as db:
        message = (p["message"] + "".join(d["note"] for d in p["deliveries"]))[:1000]
        ids = [uuid.UUID(i) for i in p["notification_ids"]]
        await db.execute(update(Notification).where(Notification.tenant_id == job.tenant_id, Notification.id.in_(ids)).values(message=message))
        await jobs.save_payload(db, job)

    unsent = [d for d in p["deliveries"] if not d["done"]]
    if unsent:
        raise RuntimeError("; ".join(d["note"].strip(" ()") for d in unsent))
    return "".join(d["note"] for d in p["deliveries"]).strip()


async def create_task_notification(
    db: AsyncSession,
    tenant_id: uuid.UUID,
    title: str,
    message: str,
    role_ids=None,
    user_ids=None,
) -> int:
    """A Task's create_notification node writes here directly — no
    Connector, no workflow instance; just rows in the same in-app
    notification list dispatch_step_notifications above feeds. Returns how
    many rows were written (0 when targeted recipients resolved to nobody)."""
    recipients = await resolve_recipient_users(db, tenant_id, role_ids, user_ids) if is_targeted(role_ids, user_ids) else None
    rows = _add_rows(db, recipients, tenant_id=tenant_id, title=(title or "Notification")[:200], message=message[:1000], source="task")
    await db.flush()
    return len(rows)
