"""Settings router — cemetery profile and fee catalog."""
import logging
from pathlib import Path
from uuid import UUID
from typing import Literal, Optional

import arq
import boto3
from arq.connections import RedisSettings
from botocore.exceptions import BotoCoreError, ClientError
from fastapi import APIRouter, Depends, Query, Request, UploadFile, File, HTTPException
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from src.core.config import settings
from src.core.dependencies import require_min_role, require_tenant
from src.core.constants import UserRole
from src.core.schemas.response import success, paginated
from src.core.exceptions import (
    ConflictError,
    NotFoundError,
    RateLimitError,
    ServiceUnavailableError,
    ValidationError,
)
from src.database.session import get_db, get_redis
from src.apps.auth.models.user import User
from src.apps.tenants.models.account import Account
from src.apps.tenants.models.branding_config import BrandingConfig
from src.apps.settings.models.fee_item import FeeItem
from src.apps.plots.models.plot import Plot
from src.apps.plots.models.plot_type import PlotType
from src.apps.sections.models.section import Section
from src.apps.settings.schemas.requests import (
    FeeItemCreateRequest,
    FeeItemUpdateRequest,
    CemeteryProfileUpdateRequest,
    PlotTypeCreateRequest,
    PlotTypeUpdateRequest,
    GenerateQRRequest,
    RegenerateAllRequest,
)
from src.apps.settings.schemas.responses import (
    FeeItemResponse,
    CemeteryProfileResponse,
    QRCodeResponse,
)
from src.apps.settings.schemas.email_template import (
    EmailTemplateCreate,
    EmailTemplatePatch,
    EmailTemplateResponse,
    EmailTemplateTestRequest,
    EmailTemplateUpdate,
)
from src.apps.settings.services.qr_code_service import QRCodeService
from src.apps.settings.services.email_template_service import EmailTemplateService
from src.apps.site_admin.services.audit_service import AuditService

_EMAIL_TEST_RATE_LIMIT = 5
_EMAIL_TEST_RATE_WINDOW_SECONDS = 3600

_LOGO_ALLOWED_MIME = {"image/png", "image/svg+xml"}
_LOGO_MAX_BYTES = 2 * 1024 * 1024  # 2 MB

# Uploads dir is at <repo_root>/uploads/, served at /uploads by main.py's StaticFiles mount.
_LOCAL_UPLOADS_ROOT = Path(__file__).resolve().parent.parent.parent.parent / "uploads"

logger = logging.getLogger(__name__)


def _save_logo_locally(request: Request, tenant_id: str, filename: str, content: bytes) -> str:
    dest = _LOCAL_UPLOADS_ROOT / "tenants" / tenant_id / "logo"
    dest.mkdir(parents=True, exist_ok=True)
    (dest / filename).write_bytes(content)
    base = str(request.base_url).rstrip("/")
    return f"{base}/uploads/tenants/{tenant_id}/logo/{filename}"

router = APIRouter(prefix="/settings", tags=["settings"])


def _serialize_plot_type(pt, in_use: int = 0) -> dict:
    w = float(pt.default_width_m) if pt.default_width_m else None
    l_ = float(pt.default_length_m) if pt.default_length_m else None
    dims = f"{w} m × {l_} m" if w and l_ else None
    linked_sections = list(getattr(pt, "sections", None) or [])
    return {
        "id": str(pt.id),
        "name": pt.name,
        "dimensions": dims,
        "default_width_m": w,
        "default_length_m": l_,
        "default_depth_m": float(pt.default_depth_m) if pt.default_depth_m is not None else None,
        "capacity": pt.capacity,
        "default_price": float(pt.default_price) if pt.default_price is not None else None,
        "section_ids": [str(s.id) for s in linked_sections],
        "sections": [
            {"id": str(s.id), "code": s.code, "name": s.name} for s in linked_sections
        ],
        "in_use": in_use,
        "created_at": pt.created_at.isoformat(),
        "updated_at": pt.updated_at.isoformat(),
    }


async def _resolve_tenant_sections(
    db: AsyncSession, tenant_id: str, section_ids: list
) -> list:
    """Load Section rows for the given ids, scoped to tenant_id.

    Raises ValidationError (422) if any requested id doesn't resolve to a
    section owned by this tenant (closes the cross-tenant linking gap called
    out in the INDL-51 Security Checklist).
    """
    unique_ids = list({sid for sid in section_ids})
    if not unique_ids:
        return []
    result = await db.execute(
        select(Section).where(
            Section.id.in_(unique_ids), Section.tenant_id == tenant_id
        )
    )
    sections = result.scalars().all()
    found_ids = {s.id for s in sections}
    missing = [sid for sid in unique_ids if sid not in found_ids]
    if missing:
        raise ValidationError(
            f"One or more section_ids are invalid or do not belong to this cemetery: "
            f"{', '.join(str(m) for m in missing)}"
        )
    return sections


# --- Cemetery profile ---

_BRANDING_FIELDS = {"public_site_name", "location_tagline", "accent_color"}
_ACCOUNT_PROFILE_FIELDS = {
    "organization_name", "cemetery_type", "contact_email", "contact_phone",
    "address", "established_year", "config_json",
}


async def _load_account_with_branding(db: AsyncSession, tenant_id: str) -> Account:
    account = (await db.execute(
        select(Account).where(Account.id == tenant_id)
    )).scalar_one_or_none()
    if account is not None:
        branding = (await db.execute(
            select(BrandingConfig).where(BrandingConfig.account_id == account.id)
        )).scalar_one_or_none()
        object.__setattr__(account, "branding", branding)
    return account


@router.get("/profile")
async def get_profile(
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    account = await _load_account_with_branding(db, tenant_id)
    if not account:
        raise NotFoundError("Cemetery profile not found")
    return success(CemeteryProfileResponse.model_validate(account))


@router.patch("/profile")
async def update_profile(
    body: CemeteryProfileUpdateRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    account = await _load_account_with_branding(db, tenant_id)
    if not account:
        raise NotFoundError("Cemetery profile not found")

    dumped = body.model_dump(exclude_unset=True)
    account_updates = {k: v for k, v in dumped.items() if k in _ACCOUNT_PROFILE_FIELDS}
    branding_updates = {k: v for k, v in dumped.items() if k in _BRANDING_FIELDS}

    for field, value in account_updates.items():
        setattr(account, field, value)

    if branding_updates:
        existing = (await db.execute(
            select(BrandingConfig).where(BrandingConfig.account_id == account.id)
        )).scalar_one_or_none()
        if existing is None:
            branding = BrandingConfig(account_id=account.id, **branding_updates)
            db.add(branding)
        else:
            for field, value in branding_updates.items():
                setattr(existing, field, value)

    await db.flush()
    account = await _load_account_with_branding(db, tenant_id)
    return success(CemeteryProfileResponse.model_validate(account))


@router.put("/profile/logo")
async def upload_logo(
    request: Request,
    logo: UploadFile = File(...),
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    if logo.content_type not in _LOGO_ALLOWED_MIME:
        raise HTTPException(status_code=400, detail="Logo must be a PNG or SVG file.")

    content = await logo.read()
    if len(content) > _LOGO_MAX_BYTES:
        raise HTTPException(status_code=400, detail="Logo must be a PNG or SVG under 2 MB.")

    safe_filename = logo.filename or "logo.png"
    s3_configured = bool(settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY)

    if s3_configured:
        s3_key = f"tenants/{tenant_id}/logo/{safe_filename}"
        try:
            s3 = boto3.client(
                "s3",
                region_name=settings.AWS_REGION,
                aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
                aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
            )
            s3.put_object(
                Bucket=settings.S3_BUCKET,
                Key=s3_key,
                Body=content,
                ContentType=logo.content_type,
            )
            logo_url = f"{settings.CLOUDFRONT_URL}/{s3_key}"
        except (BotoCoreError, ClientError) as exc:
            logger.warning("[settings/logo] S3 upload failed, falling back to local storage: %s", exc)
            logo_url = _save_logo_locally(request, tenant_id, safe_filename, content)
    else:
        logger.info("[settings/logo] S3 not configured — saving to local storage")
        logo_url = _save_logo_locally(request, tenant_id, safe_filename, content)

    account = await _load_account_with_branding(db, tenant_id)
    if not account:
        raise NotFoundError("Cemetery profile not found")

    existing_branding = (await db.execute(
        select(BrandingConfig).where(BrandingConfig.account_id == account.id)
    )).scalar_one_or_none()
    if existing_branding is None:
        branding = BrandingConfig(account_id=account.id, logo_url=logo_url)
        db.add(branding)
    else:
        existing_branding.logo_url = logo_url

    await db.flush()
    return success({"logo_url": logo_url})


# --- Fee catalog ---

@router.get("/fees")
async def list_fees(
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
    category: Optional[str] = Query(None),
    active_only: bool = Query(True),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    filters = [FeeItem.tenant_id == tenant_id]
    if category:
        filters.append(FeeItem.category == category)
    if active_only:
        filters.append(FeeItem.is_active.is_(True))

    total = (
        await db.execute(select(func.count()).select_from(FeeItem).where(*filters))
    ).scalar_one()
    offset = (page - 1) * page_size
    result = await db.execute(
        select(FeeItem).where(*filters)
        .order_by(FeeItem.sort_order, FeeItem.name)
        .offset(offset).limit(page_size)
    )
    items = [FeeItemResponse.model_validate(f) for f in result.scalars().all()]
    return paginated(items, total, page, page_size)


@router.post("/fees", status_code=201)
async def create_fee(
    body: FeeItemCreateRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    fee = FeeItem(tenant_id=tenant_id, **body.model_dump())
    db.add(fee)
    await db.flush()
    await db.refresh(fee)
    return success(FeeItemResponse.model_validate(fee), "Fee item created")


@router.patch("/fees/{fee_id}")
async def update_fee(
    fee_id: UUID,
    body: FeeItemUpdateRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(FeeItem).where(FeeItem.id == fee_id, FeeItem.tenant_id == tenant_id)
    )
    fee = result.scalar_one_or_none()
    if not fee:
        raise NotFoundError("Fee item not found")
    for field, value in body.model_dump(exclude_unset=True).items():
        setattr(fee, field, value)
    await db.flush()
    await db.refresh(fee)
    return success(FeeItemResponse.model_validate(fee))


@router.delete("/fees/{fee_id}", status_code=204)
async def delete_fee(
    fee_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(FeeItem).where(FeeItem.id == fee_id, FeeItem.tenant_id == tenant_id)
    )
    fee = result.scalar_one_or_none()
    if not fee:
        raise NotFoundError("Fee item not found")
    await db.delete(fee)


# --- QR Codes ---

@router.get("/qr-codes")
async def list_qr_codes(
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    qr_type: Optional[str] = Query(None),
    sort_by: Literal["qr_type", "generated_at", "created_at"] = Query("qr_type"),
    order: Literal["asc", "desc"] = Query("asc"),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    items, total = await QRCodeService.get_list(
        db, tenant_id, qr_type=qr_type, page=page, page_size=page_size,
        sort_by=sort_by, order=order,
    )
    data = [QRCodeResponse.model_validate(q) for q in items]
    return paginated(data, total, page, page_size)


@router.post("/qr-codes/generate", status_code=202)
async def generate_qr_code_endpoint(
    request: Request,
    body: GenerateQRRequest,
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    arq_redis = None
    try:
        arq_redis = await arq.create_pool(RedisSettings.from_dsn(settings.REDIS_URL))
    except Exception as exc:
        logger.warning(f"[qr] Redis unavailable: {exc}")
    try:
        result = await QRCodeService.generate_one(
            db,
            tenant_id,
            qr_type=body.qr_type,
            reference_id=body.reference_id,
            display_label=body.display_label,
            arq_redis=arq_redis,
        )
    finally:
        if arq_redis is not None:
            await arq_redis.aclose()

    await AuditService.log(
        db,
        "qr_codes",
        UUID(str(tenant_id)),
        "generate",
        current_user,
        request,
        old_value=None,
        new_value={
            "qr_type": body.qr_type,
            "reference_id": body.reference_id,
            "qr_code_id": result.get("qr_code_id"),
        },
        raise_on_error=False,
    )

    return success(result, "QR code generation enqueued")


@router.post("/qr-codes/regenerate-all", status_code=202)
async def regenerate_all_qr_codes(
    request: Request,
    body: RegenerateAllRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    arq_redis = None
    try:
        arq_redis = await arq.create_pool(RedisSettings.from_dsn(settings.REDIS_URL))
    except Exception as exc:
        logger.warning(f"[qr] Redis unavailable: {exc}")
    try:
        result = await QRCodeService.regenerate_all(
            db,
            tenant_id,
            qr_type=body.qr_type,
            arq_redis=arq_redis,
        )
    finally:
        if arq_redis is not None:
            await arq_redis.aclose()

    await AuditService.log(
        db,
        "qr_codes",
        UUID(str(tenant_id)),
        "regenerate_all",
        current_user,
        request,
        old_value=None,
        new_value={"qr_type": body.qr_type, "enqueued": result["enqueued"]},
        raise_on_error=False,
    )

    return success(result, f"Enqueued {result['enqueued']} QR generation jobs")


@router.post("/qr-codes/bulk-headstones", status_code=202)
async def bulk_headstone_qr_export(
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await QRCodeService.check_bulk_headstone_rate_limit(tenant_id)

    arq_redis = None
    try:
        arq_redis = await arq.create_pool(RedisSettings.from_dsn(settings.REDIS_URL))
    except Exception as exc:
        logger.warning(f"[qr] Redis unavailable: {exc}")
    try:
        result = await QRCodeService.bulk_headstone_export(
            db, tenant_id, arq_redis=arq_redis
        )
    finally:
        if arq_redis is not None:
            await arq_redis.aclose()

    await AuditService.log(
        db,
        "qr_codes",
        UUID(str(tenant_id)),
        "bulk_headstone_export",
        current_user,
        request,
        old_value=None,
        new_value=result,
        raise_on_error=False,
    )

    return success(
        result, f"Enqueued {result['enqueued']} headstone QR generation jobs"
    )


@router.get("/qr-codes/{qr_id}/download")
async def download_qr_code(
    qr_id: UUID,
    format: Optional[Literal["svg", "pdf"]] = Query(None),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    url = await QRCodeService.get_download_url(db, tenant_id, qr_id, file_format=format)
    return success({"download_url": url})


@router.delete("/qr-codes/{qr_id}", status_code=204)
async def delete_qr_code(
    qr_id: UUID,
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    qr = await QRCodeService.deactivate(db, tenant_id, qr_id)
    await AuditService.log(
        db,
        "qr_codes",
        qr.id,
        "delete",
        current_user,
        request,
        old_value={"is_active": True},
        new_value={"is_active": False, "qr_type": qr.qr_type, "reference_id": qr.reference_id},
        raise_on_error=False,
    )


# ── Plot Types ────────────────────────────────────────────────────────────────

@router.get("/plot-types", response_model=dict)
async def list_plot_types(
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
    current_user: User = Depends(require_min_role(UserRole.STAFF)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    offset = (page - 1) * page_size
    count_result = await db.execute(
        select(func.count(PlotType.id)).where(PlotType.tenant_id == tenant_id)
    )
    total = count_result.scalar_one()
    result = await db.execute(
        select(PlotType)
        .options(selectinload(PlotType.sections))
        .where(PlotType.tenant_id == tenant_id)
        .order_by(PlotType.name.asc()).offset(offset).limit(page_size)
    )
    items = result.scalars().all()

    # Single grouped query for the whole page — avoids N+1 (INDL-51 AC-12).
    in_use_map: dict = {}
    if items:
        ids = [pt.id for pt in items]
        counts = await db.execute(
            select(Plot.plot_type_id, func.count(Plot.id))
            .where(Plot.tenant_id == tenant_id, Plot.plot_type_id.in_(ids))
            .group_by(Plot.plot_type_id)
        )
        in_use_map = {ptid: cnt for ptid, cnt in counts.all()}

    return paginated(
        items=[_serialize_plot_type(pt, in_use_map.get(pt.id, 0)) for pt in items],
        total=total, page=page, page_size=page_size,
    )


@router.post("/plot-types", response_model=dict, status_code=201)
async def create_plot_type(
    body: PlotTypeCreateRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    existing = await db.execute(
        select(PlotType).where(
            PlotType.tenant_id == tenant_id,
            func.lower(PlotType.name) == body.name.lower(),
        )
    )
    if existing.scalar_one_or_none():
        raise ConflictError(f"A plot type named '{body.name}' already exists.")

    sections = await _resolve_tenant_sections(db, tenant_id, body.section_ids)

    pt = PlotType(
        tenant_id=tenant_id,
        name=body.name,
        default_width_m=body.default_width_m,
        default_length_m=body.default_length_m,
        default_depth_m=body.default_depth_m,
        capacity=body.capacity,
        default_price=body.default_price,
        sections=sections,
    )
    db.add(pt)
    try:
        await db.flush()
    except IntegrityError as exc:
        # Defense in depth: the SELECT pre-check above has a race window
        # between two concurrent creates with the same name. The
        # uq_plot_types_tenant_name_ci partial unique index (migration 0064)
        # is the safety net — translate the constraint violation back into
        # the same 409 the pre-check raises (mirrors email_template_service.py).
        await db.rollback()
        raise ConflictError(f"A plot type named '{body.name}' already exists.") from exc
    result = await db.execute(
        select(PlotType)
        .options(selectinload(PlotType.sections))
        .where(PlotType.id == pt.id)
    )
    pt = result.scalar_one()
    return success(data=_serialize_plot_type(pt, in_use=0), message="Plot type created")


@router.patch("/plot-types/{plot_type_id}", response_model=dict)
async def update_plot_type(
    plot_type_id: UUID,
    body: PlotTypeUpdateRequest,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(PlotType)
        .options(selectinload(PlotType.sections))
        .where(PlotType.id == plot_type_id, PlotType.tenant_id == tenant_id)
    )
    pt = result.scalar_one_or_none()
    if not pt:
        raise NotFoundError("Plot type not found")

    data = body.model_dump(exclude_unset=True)

    new_name = data.get("name")
    if new_name and new_name.lower() != pt.name.lower():
        clash = await db.execute(
            select(PlotType).where(
                PlotType.tenant_id == tenant_id,
                func.lower(PlotType.name) == new_name.lower(),
                PlotType.id != plot_type_id,
            )
        )
        if clash.scalar_one_or_none():
            raise ConflictError(f"A plot type named '{new_name}' already exists.")

    section_ids = data.pop("section_ids", None)
    if section_ids is not None:
        pt.sections = await _resolve_tenant_sections(db, tenant_id, section_ids)

    allowed = [
        "name", "default_width_m", "default_length_m", "default_depth_m",
        "capacity", "default_price",
    ]
    for field in allowed:
        if field in data:
            setattr(pt, field, data[field])

    try:
        await db.flush()
    except IntegrityError as exc:
        # Same race-window backstop as create_plot_type above.
        await db.rollback()
        raise ConflictError(
            f"A plot type named '{data.get('name', pt.name)}' already exists."
        ) from exc
    result = await db.execute(
        select(PlotType)
        .options(selectinload(PlotType.sections))
        .where(PlotType.id == pt.id)
    )
    pt = result.scalar_one()
    in_use_count = (
        await db.execute(
            select(func.count(Plot.id)).where(
                Plot.tenant_id == tenant_id, Plot.plot_type_id == pt.id
            )
        )
    ).scalar_one()
    return success(data=_serialize_plot_type(pt, in_use=in_use_count))


@router.delete("/plot-types/{plot_type_id}", status_code=204)
async def delete_plot_type(
    plot_type_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    result = await db.execute(
        select(PlotType).where(PlotType.id == plot_type_id, PlotType.tenant_id == tenant_id)
    )
    pt = result.scalar_one_or_none()
    if not pt:
        raise NotFoundError("Plot type not found")

    in_use_count = (
        await db.execute(
            select(func.count(Plot.id)).where(
                Plot.tenant_id == tenant_id, Plot.plot_type_id == plot_type_id
            )
        )
    ).scalar_one()
    if in_use_count > 0:
        raise ConflictError("Plot type is in use and cannot be deleted.")

    await db.delete(pt)


# ── Email Templates (INDL-32) ───────────────────────────────────────────────

def _client_ip(request: Request) -> Optional[str]:
    return request.client.host if request.client else None


@router.get("/email-templates", response_model=dict)
async def list_email_templates(
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    items = await EmailTemplateService.list_for_tenant(db, tenant_id)
    return success(
        data={
            "items": [EmailTemplateResponse.model_validate(t).model_dump(mode="json") for t in items],
            "total": len(items),
        }
    )


@router.get("/email-templates/triggers", response_model=dict)
async def list_email_template_triggers(
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    """Canonical trigger catalog + this tenant's current mapping (INDL-54).

    Registered before `/email-templates/{template_id}` so "triggers" is not
    captured as a template_id path param.
    """
    from src.core.email_dispatch import email_dispatch_service

    catalog = await email_dispatch_service.list_trigger_catalog(db, tenant_id)
    return success(data={"items": catalog, "total": len(catalog)})


@router.post("/email-templates", response_model=dict, status_code=201)
async def create_email_template(
    payload: EmailTemplateCreate,
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    template = await EmailTemplateService.create(
        db,
        tenant_id=tenant_id,
        payload=payload,
        user_id=current_user.id,
        ip_address=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
    )
    return success(data=EmailTemplateResponse.model_validate(template).model_dump(mode="json"))


@router.get("/email-templates/{template_id}", response_model=dict)
async def get_email_template(
    template_id: UUID,
    current_user: User = Depends(require_min_role(UserRole.VIEW_ONLY)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    template = await EmailTemplateService.get_by_id(db, tenant_id, template_id)
    if not template:
        raise NotFoundError("Template not found")
    return success(data=EmailTemplateResponse.model_validate(template).model_dump(mode="json"))


@router.put("/email-templates/{template_id}", response_model=dict)
async def update_email_template(
    template_id: UUID,
    payload: EmailTemplateUpdate,
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    template = await EmailTemplateService.update(
        db,
        tenant_id=tenant_id,
        template_id=template_id,
        payload=payload,
        user_id=current_user.id,
        ip_address=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
    )
    return success(data=EmailTemplateResponse.model_validate(template).model_dump(mode="json"))


@router.patch("/email-templates/{template_id}", response_model=dict)
async def patch_email_template_status(
    template_id: UUID,
    payload: EmailTemplatePatch,
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    template = await EmailTemplateService.patch_status(
        db,
        tenant_id=tenant_id,
        template_id=template_id,
        status=payload.status,
        user_id=current_user.id,
        ip_address=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
    )
    return success(data={"id": str(template.id), "status": template.status})


@router.delete("/email-templates/{template_id}", response_model=dict)
async def delete_email_template(
    template_id: UUID,
    request: Request,
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    await EmailTemplateService.soft_delete(
        db,
        tenant_id=tenant_id,
        template_id=template_id,
        user_id=current_user.id,
        ip_address=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
    )
    return success(message="Template deleted")


@router.post("/email-templates/{template_id}/test", response_model=dict)
async def send_test_email_template(
    template_id: UUID,
    request: Request,
    body: EmailTemplateTestRequest = EmailTemplateTestRequest(),
    current_user: User = Depends(require_min_role(UserRole.ADMINISTRATOR)),
    tenant_id: str = Depends(require_tenant),
    db: AsyncSession = Depends(get_db),
):
    # Redis-backed rate limit: 5 sends/hour per (user, template). Unlike
    # non-destructive internal limiters elsewhere in this codebase (e.g.
    # sections.section_service._check_autogen_rate_limit, which fails open
    # by design), this endpoint dispatches real outbound email — so it must
    # fail CLOSED if Redis is unreachable rather than allow unlimited sends.
    redis_client = await get_redis()
    if redis_client is None:
        raise ServiceUnavailableError(
            "Test email sending is temporarily unavailable — please try again shortly."
        )
    try:
        key = f"ratelimit:email_template_test:{current_user.id}:{template_id}"
        count = await redis_client.incr(key)
        if count == 1:
            await redis_client.expire(key, _EMAIL_TEST_RATE_WINDOW_SECONDS)
        if count > _EMAIL_TEST_RATE_LIMIT:
            ttl = await redis_client.ttl(key)
            raise RateLimitError(
                "Too many test emails sent for this template — please try again later.",
                retry_after=max(int(ttl), 1),
            )
    finally:
        try:
            await redis_client.aclose()
        except Exception:
            pass

    recipient = await EmailTemplateService.send_test(
        db,
        tenant_id=tenant_id,
        template_id=template_id,
        requesting_user=current_user,
        to_email=body.to_email,
        ip_address=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
    )
    return success(message=f"Test email sent to {recipient}")
