"""Outbound email — decided here, delivered through the account service.

The transport is `bw_auth.send_mail` (the kit): easel hands the account
service a username, a subject and a body, and the gateway, which alone holds
the Resend key and the addresses, mails the account's stored address. It only
does so for a user who has actually signed into easel, and within daily caps,
so a compromised app could not relay branded mail to anyone. rian connected it
on 2026-09-07; before that every message was held as "no_transport".

Every message easel decides to send is still written to the outbox first, sent
or not, with the reason when it did not go. That buys two things:

  * **The email design is testable before a transport exists.** You can read
    exactly what would have reached whom, and fix the wording, without anything
    leaving the building.
  * **Dev Mode is more than a switch.** A suppressed message is still recorded,
    so "we turned it off and nothing was lost" is checkable rather than hopeful.

When a transport lands, `_deliver()` gets a body and everything above it stays.

**Two moments earn an email and no others** — "your concepts are ready" and "we
answered your note". Everything else is the bell. A tool that mails on every
event trains people to ignore it, and the one message that mattered goes with
the rest.
"""

import logging
from datetime import datetime, timedelta, timezone

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models import AppSetting, OutboundMessage

log = logging.getLogger(__name__)

DEV_MODE_KEY = "dev_mode"


def dev_mode(db: Session) -> bool:
    """Default ON. The safe default for a switch whose off position mails
    clients is the one that does not."""
    row = db.get(AppSetting, DEV_MODE_KEY)
    return True if row is None else row.value == "on"


def set_dev_mode(db: Session, on: bool) -> None:
    row = db.get(AppSetting, DEV_MODE_KEY)
    if row is None:
        row = AppSetting(key=DEV_MODE_KEY)
        db.add(row)
    row.value = "on" if on else "off"
    db.flush()


def _address_for(username: str) -> str:
    """Best effort. easel stores no addresses of its own — BW is the directory."""
    try:
        from app import bw_auth

        return (bw_auth.userinfo(username) or {}).get("email", "") or ""
    except Exception:  # noqa: BLE001 - a missing address holds a message, never breaks a request
        return ""


RETRYABLE = ("no_transport", "send_failed")
RETRY_WINDOW_HOURS = 24


def _text_of(message: OutboundMessage) -> str:
    """The mail body: the message, the link if there is one, the signature."""
    text = (message.body or "").rstrip()
    if message.url:
        text += f"\n\nOpen it here:\n\n  {message.url}"
    return text + "\n\n— Bowden Works"


def _deliver(message: OutboundMessage) -> tuple[bool, str]:
    """The transport: the account service mails the account's stored address.
    Returns (sent, held_reason). A refusal or an outage holds the message as
    "send_failed", which `retry_held` picks up again for a day."""
    try:
        from app import bw_auth

        bw_auth.send_mail(message.recipient, message.subject, _text_of(message))
        return True, ""
    except Exception as exc:  # noqa: BLE001 - a failed send holds a message, never breaks a request
        log.warning("outbound send to %s failed: %s", message.recipient, exc)
        return False, "send_failed"


def queue(db: Session, *, recipient: str, kind: str, subject: str,
          body: str, url: str = "", project_id: str | None = None) -> OutboundMessage:
    """Decide to send. Records either way, and says why when it does not."""
    message = OutboundMessage(
        recipient=recipient, to_email=_address_for(recipient), kind=kind,
        subject=subject[:200], body=body, url=url, project_id=project_id)

    if dev_mode(db):
        message.held_reason = "dev_mode"
    else:
        sent, why = _deliver(message)
        if sent:
            message.sent_at = datetime.now(timezone.utc)
        else:
            message.held_reason = why

    db.add(message)
    db.flush()
    log.info("outbound %s -> %s (%s)", kind, recipient,
             message.held_reason or "sent")
    return message


def retry_held(db: Session, *, now: datetime | None = None) -> int:
    """Try again for messages a transport failure held, for a day. A message
    Dev Mode held stays held: that was a decision, not an outage. Returns
    how many went."""
    if dev_mode(db):
        return 0
    now = now or datetime.now(timezone.utc)
    sent = 0
    for m in db.scalars(select(OutboundMessage).where(
            OutboundMessage.sent_at.is_(None),
            OutboundMessage.held_reason.in_(RETRYABLE))).all():
        created = m.created_at if m.created_at.tzinfo else m.created_at.replace(tzinfo=timezone.utc)
        if created < now - timedelta(hours=RETRY_WINDOW_HOURS):
            continue
        ok, why = _deliver(m)
        if ok:
            m.sent_at = now
            m.held_reason = ""
            sent += 1
        else:
            m.held_reason = why
    db.flush()
    return sent


def held_count(db: Session) -> int:
    return len(db.scalars(select(OutboundMessage).where(
        OutboundMessage.sent_at.is_(None))).all())
