"""
The workspace's own emails about people's accounts: a password reset link
("Forgot password?"), a notice that a password changed, and a confirmation
that a Public Portal registration was received.

Each goes through an email connector (by default the workspace's first
enabled email_smtp connector). Its template — subject and plain-text body
with {{placeholders}} — can be edited or switched off under Settings →
Email (tenants.email_settings; {} = every template on, as written below).

They're sent by a background job (app/core/jobs.py): a slow or failing mail
server never holds up the request, a failed send is retried, and how long
"Forgot password?" takes never tells anyone whether an account exists. The
values a template fills in — a reset link among them — are encrypted in
the job.
"""

import asyncio
import hashlib
import json
import re
import secrets
import smtplib
import uuid
from datetime import datetime, timedelta, timezone
from typing import Literal

from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import jobs
from app.core.crypto import decrypt_secret, encrypt_secret
from app.crud.connectors import first_enabled_connector
from app.crud.secrets import connector_config
from app.crud.tasks import _send_email_sync
from app.models.reporting import Connector
from app.models.identity import Tenant, User

TemplateKey = Literal["password_reset", "password_changed", "registration_received"]

RESET_TTL = timedelta(minutes=30)
# One reset email per account this often, whoever asks.
RESET_COOLDOWN = timedelta(seconds=60)


class TemplateInfo(BaseModel):
    key: TemplateKey
    label: str
    description: str
    subject: str
    body: str
    variables: list[str]


TEMPLATES: dict[str, TemplateInfo] = {
    t.key: t
    for t in [
        TemplateInfo(
            key="password_reset",
            label="Password reset",
            description="Sent when someone uses “Forgot password?” on the sign-in page. Without it, that link isn't offered.",
            subject="Reset your {{workspace}} password",
            body=(
                "Hello {{name}},\n\n"
                "We received a request to reset the password for your {{workspace}} account. To choose a new one, open this link:\n\n"
                "{{link}}\n\n"
                "The link works once and expires in {{expires_in}}. If you didn't ask for this, you can ignore this email — "
                "your password stays as it is."
            ),
            variables=["name", "email", "workspace", "link", "expires_in"],
        ),
        TemplateInfo(
            key="password_changed",
            label="Password changed",
            description="Sent after someone's password changes — by them, through a reset link, or by an administrator.",
            subject="Your {{workspace}} password was changed",
            body=(
                "Hello {{name}},\n\n"
                "The password for your {{workspace}} account ({{email}}) was changed on {{time}}.\n\n"
                "If this wasn't you, contact your administrator right away."
            ),
            variables=["name", "email", "workspace", "time"],
        ),
        TemplateInfo(
            key="registration_received",
            label="Registration received",
            description="Sent to someone who registers on the Public Portal, confirming their registration.",
            subject="Your {{workspace}} registration",
            body=(
                "Hello {{name}},\n\n"
                "Thank you for registering with {{workspace}} for {{organization}}. Your registration has been received; "
                "you'll be able to sign in once an administrator approves your account.\n\n"
                "Your sign-in email is {{email}}."
            ),
            variables=["name", "email", "workspace", "organization"],
        ),
    ]
}


class TemplateSetting(BaseModel):
    enabled: bool = True
    # None = the default text above.
    subject: str | None = Field(default=None, max_length=300)
    body: str | None = Field(default=None, max_length=10_000)


class EmailSettings(BaseModel):
    # None = the workspace's first enabled email_smtp connector.
    connector_id: uuid.UUID | None = None
    templates: dict[TemplateKey, TemplateSetting] = {}

    def template(self, key: str) -> TemplateSetting:
        return self.templates.get(key) or TemplateSetting()  # type: ignore[call-overload]


class TemplateRead(TemplateInfo):
    enabled: bool
    # What's set here — None when it's the default.
    custom_subject: str | None = None
    custom_body: str | None = None


class EmailSettingsRead(BaseModel):
    connector_id: uuid.UUID | None
    # The connector they'd go through now, and whether there's one at all.
    connector_name: str | None
    templates: list[TemplateRead]


def settings_of(tenant: Tenant) -> EmailSettings:
    return EmailSettings.model_validate(tenant.email_settings or {})


async def _connector(db: AsyncSession, tenant_id: uuid.UUID, s: EmailSettings) -> Connector | None:
    if s.connector_id:
        chosen = (
            await db.execute(select(Connector).where(Connector.tenant_id == tenant_id, Connector.id == s.connector_id))
        ).scalar_one_or_none()
        return chosen if chosen and chosen.status == "enabled" and chosen.type == "email_smtp" else None
    return await first_enabled_connector(db, tenant_id, "email_smtp")


async def read(db: AsyncSession, tenant: Tenant) -> EmailSettingsRead:
    s = settings_of(tenant)
    connector = await _connector(db, tenant.id, s)
    templates = []
    for key, info in TEMPLATES.items():
        t = s.template(key)
        templates.append(TemplateRead(**info.model_dump(), enabled=t.enabled, custom_subject=t.subject, custom_body=t.body))
    return EmailSettingsRead(connector_id=s.connector_id, connector_name=connector.name if connector else None, templates=templates)


async def write(db: AsyncSession, tenant: Tenant, payload: EmailSettings) -> None:
    if payload.connector_id:
        chosen = (
            await db.execute(select(Connector).where(Connector.tenant_id == tenant.id, Connector.id == payload.connector_id))
        ).scalar_one_or_none()
        if chosen is None or chosen.type != "email_smtp":
            raise ValueError("Choose an email (SMTP) connector.")
    # Text that's the same as the default is stored as "default", so it
    # follows any later change to the default wording.
    for key, t in payload.templates.items():
        info = TEMPLATES[key]
        if t.subject is not None and (not t.subject.strip() or t.subject.strip() == info.subject):
            t.subject = None
        if t.body is not None and (not t.body.strip() or t.body.strip() == info.body):
            t.body = None
    tenant.email_settings = payload.model_dump(mode="json", exclude_defaults=True)


_PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")


def render(s: EmailSettings, key: str, values: dict[str, str]) -> tuple[str, str]:
    """The subject and body with placeholders filled in; an unknown one is left as written."""
    info, t = TEMPLATES[key], s.template(key)
    fill = lambda text: _PLACEHOLDER.sub(lambda m: str(values.get(m.group(1), m.group(0))), text)  # noqa: E731
    # A subject is one line, whatever a value holds.
    return fill(t.subject or info.subject).replace("\r", " ").replace("\n", " "), fill(t.body or info.body)


def enabled(tenant: Tenant, key: str) -> bool:
    return settings_of(tenant).template(key).enabled


async def available(db: AsyncSession, tenant: Tenant, key: str) -> bool:
    """Whether this email would actually go out: on, with a connector to send it."""
    s = settings_of(tenant)
    return s.template(key).enabled and await _connector(db, tenant.id, s) is not None


# ---- sending ------------------------------------------------------------------------------------------------

SEND = "account_email.send"


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _when(dt: datetime) -> str:
    return dt.strftime("%d %b %Y, %H:%M UTC")


async def queue(db: AsyncSession, tenant: Tenant, key: str, user: User, extra: dict[str, str] | None = None) -> bool:
    """Queues `key` to `user`, in the caller's transaction. False when that
    email is switched off. `db` must be scoped to the tenant."""
    if not enabled(tenant, key):
        return False
    values = {"name": user.name, "email": user.email, "workspace": tenant.name, "time": _when(_now()), **(extra or {})}
    await jobs.enqueue(
        db, SEND, {"template": key, "user_id": str(user.id), "values": encrypt_secret(json.dumps(values))}, tenant_id=tenant.id, max_attempts=5
    )
    return True


async def _send(db: AsyncSession, tenant: Tenant, key: str, to: str, values: dict[str, str], s: EmailSettings | None = None) -> None:
    s = s or settings_of(tenant)
    connector = await _connector(db, tenant.id, s)
    if connector is None:
        raise LookupError("there's no enabled email connector to send it through")
    subject, body = render(s, key, values)
    config = await connector_config(db, tenant.id, connector)
    await asyncio.to_thread(_send_email_sync, config, to, subject, body)


@jobs.handler(SEND, max_attempts=5)
async def _send_job(job: jobs.Job) -> str:
    p = job.payload
    async with jobs.tenant_session(job.tenant_id) as db:
        tenant = await db.get(Tenant, job.tenant_id)
        user = await db.get(User, uuid.UUID(p["user_id"]))
        if tenant is None or user is None:
            return "skipped — the account is gone"
        try:
            await _send(db, tenant, p["template"], user.email, json.loads(decrypt_secret(p["values"])))
        except LookupError as exc:
            raise jobs.Permanent(f"not sent — {exc}") from None
        except (smtplib.SMTPException, OSError, ValueError) as exc:
            raise jobs.Retry(f"the mail server said: {exc}", delay=60 * job.attempt) from None
    return f"sent {p['template']} to {user.email}"


SAMPLE = {
    "name": "Alex Example",
    "email": "alex@example.com",
    "link": "https://example.com/reset-password?token=sample",
    "expires_in": "30 minutes",
    "organization": "Example Ltd",
}


async def send_test(db: AsyncSession, tenant: Tenant, key: str, to: User, s: EmailSettings | None = None) -> str:
    """Sends `key` with sample values to `to`, now — with settings `s` if
    given (unsaved edits), else the saved ones. Returns "sent" or why not."""
    values = {**SAMPLE, "name": to.name, "email": to.email, "workspace": tenant.name, "time": _when(_now())}
    try:
        await _send(db, tenant, key, to.email, values, s)
    except LookupError as exc:
        return f"Not sent — {exc}."
    except (smtplib.SMTPException, OSError, ValueError) as exc:
        return f"Not sent — the mail server said: {exc}"
    return "sent"


# ---- password reset ---------------------------------------------------------------------------------------


def _hash(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()


async def request_reset(db: AsyncSession, tenant: Tenant, user: User | None, base_url: str) -> None:
    """Emails `user` a reset link, when there's someone to send it to and a
    way to send it. Says nothing either way — the caller answers the same
    whether or not the account exists."""
    if user is None or user.status != "active" or not user.password_hash:
        return
    if not await available(db, tenant, "password_reset"):
        return
    if user.password_reset_sent_at and _now() - user.password_reset_sent_at < RESET_COOLDOWN:
        return
    token = secrets.token_urlsafe(32)
    user.password_reset_hash = _hash(token)
    user.password_reset_expires_at = _now() + RESET_TTL
    user.password_reset_sent_at = _now()
    link = f"{base_url.rstrip('/')}/reset-password?tenant={tenant.id}&token={token}"
    await queue(db, tenant, "password_reset", user, {"link": link, "expires_in": f"{int(RESET_TTL.total_seconds() // 60)} minutes"})


async def user_for_reset(db: AsyncSession, tenant_id: uuid.UUID, token: str) -> User | None:
    """The account a reset link is for, while it still works."""
    user = (
        await db.execute(select(User).where(User.tenant_id == tenant_id, User.password_reset_hash == _hash(token)))
    ).scalar_one_or_none()
    if user is None or not user.password_reset_expires_at or user.password_reset_expires_at < _now() or user.status != "active":
        return None
    return user


def clear_reset(user: User) -> None:
    user.password_reset_hash = None
    user.password_reset_expires_at = None
