"""Template configuration CRUD API routes.

Template content is stored in TemplateSlot child rows (one per slot_name).
The API exposes slots as a flat dict in create/update/response payloads.
"""

import logging
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from jinja2.sandbox import SandboxedEnvironment

from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, TemplateSlot, User
from ..services.sample_context import _SAMPLE_CONTEXT
from .slot_helpers import load_slots, render_template_preview, save_slots

_LOGGER = logging.getLogger(__name__)

router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])


class TemplateConfigCreate(BaseModel):
    provider_type: str
    name: str
    description: str | None = None
    icon: str | None = None
    date_format: str | None = None
    date_only_format: str | None = None
    slots: dict[str, dict[str, str]] = {}  # slot_name -> {locale -> template text}


class TemplateConfigUpdate(BaseModel):
    name: str | None = None
    description: str | None = None
    icon: str | None = None
    date_format: str | None = None
    date_only_format: str | None = None
    slots: dict[str, dict[str, str]] | None = None  # partial update


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
    return await load_slots(session, TemplateSlot, config_id)


async def _save_slots(
    session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]
) -> None:
    await save_slots(session, TemplateSlot, config_id, slots)


async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
    """Build API response dict for a TemplateConfig, including its slots."""
    slots = await _load_slots(session, c.id)
    return {
        "id": c.id,
        "user_id": c.user_id,
        "provider_type": c.provider_type,
        "name": c.name,
        "description": c.description,
        "icon": c.icon,
        "date_format": c.date_format,
        "date_only_format": c.date_only_format,
        "slots": slots,
        "created_at": c.created_at.isoformat(),
    }


async def _get(session: AsyncSession, config_id: int, user_id: int) -> TemplateConfig:
    config = await session.get(TemplateConfig, config_id)
    if not config or (config.user_id != user_id and config.user_id != 0):
        raise HTTPException(status_code=404, detail="Template config not found")
    return config


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@router.get("")
async def list_configs(
    provider_type: str | None = None,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    from sqlalchemy import or_
    query = select(TemplateConfig).where(
        or_(TemplateConfig.user_id == user.id, TemplateConfig.user_id == 0)
    )
    if provider_type:
        query = query.where(TemplateConfig.provider_type == provider_type)
    result = await session.exec(query)
    return [await _response(session, c) for c in result.all()]


@router.get("/defaults")
async def get_default_slot_templates(
    provider_type: str,
    slot_name: str | None = None,
    locale: str | None = None,
    user: User = Depends(get_current_user),
):
    """Return the shipped Jinja2 default templates for a provider type.

    Used by the UI's "Reset to default" actions. Filtering is optional —
    omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
    Registered before ``/{config_id}`` so the literal path wins over the
    path-parameter route in FastAPI's matcher.

    Response shape: ``{slot_name: {locale: template_text}}``
    """
    from notify_bridge_core.templates.defaults.loader import (
        get_available_locales,
        load_default_templates,
    )
    locales = [locale] if locale else get_available_locales()
    result: dict[str, dict[str, str]] = {}
    for loc in locales:
        defaults = load_default_templates(loc, provider_type)
        for name, text in defaults.items():
            if slot_name and name != slot_name:
                continue
            result.setdefault(name, {})[loc] = text
    return result


@router.get("/variables")
async def get_template_variables(
    user: User = Depends(get_current_user),
):
    """Get template variable reference grouped by slot.

    Returns a dict keyed by template slot name, each containing:
    - description: what the slot is for
    - variables: dict of variable_name -> description
    - asset_fields: dict of field_name -> description (for slots with assets)
    - album_fields: dict of field_name -> description (for slots with albums)
    """
    # Core event variables available in all event templates
    event_vars = {
        "collection_id": "Collection ID (UUID)",
        "collection_name": "Collection name",
        "collection_url": "Public share URL (empty if not shared)",
        "public_url": "Public share link URL (empty if no link exists)",
        "protected_url": "Password-protected share link URL (empty if none)",
        "added_count": "Number of assets added",
        "removed_count": "Number of assets removed",
        "people": "Detected people names (list, use {{ people | join(', ') }})",
        "shared": "Whether collection is shared (boolean)",
        "photo_count": "Total photo count in album",
        "video_count": "Total video count in album",
        "owner": "Album owner name",
        "target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
        "has_videos": "Whether added assets contain videos (boolean)",
        "has_photos": "Whether added assets contain photos (boolean)",
        "has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
        "max_video_size": "Target video size limit in bytes (null if no limit)",
        "max_video_size_mb": "Target video size limit in MB (null if no limit)",
        # Provider-specific aliases
        "album_name": "Alias for collection_name",
        "album_id": "Alias for collection_id",
        "album_url": "Alias for collection_url (Immich) or album product URL (Google Photos)",
    }
    rename_vars = {
        **event_vars,
        "old_name": "Previous name (rename events)",
        "new_name": "New name (rename events)",
    }
    sharing_vars = {
        **event_vars,
        "old_shared": "Was shared before change (boolean)",
        "new_shared": "Is shared after change (boolean)",
    }
    asset_fields = {
        "id": "Asset ID (UUID)",
        "filename": "Original filename",
        "type": "IMAGE or VIDEO",
        "created_at": "Creation date/time (ISO 8601)",
        "owner": "Owner display name",
        "description": "User or EXIF description",
        "people": "People detected in this asset (list)",
        "is_favorite": "Whether asset is favorited (boolean)",
        "rating": "Star rating (1-5 or null)",
        "city": "City name",
        "state": "State/region name",
        "country": "Country name",
        "file_size": "Original asset size in bytes (null if unknown)",
        "playback_size": "Size in bytes of the media we actually upload — for Immich videos this is the transcoded /video/playback (null for photos or when unknown)",
        "oversized": "Whether the asset's playback_size exceeds the target's size limit (boolean, videos only)",
        "public_url": "Per-asset public share URL (empty if no album link)",
        "url": "Public viewer URL (if shared)",
        "download_url": "Direct download URL (if shared)",
        "photo_url": "Preview image URL (images only, if shared)",
        "playback_url": "Video playback URL (videos only, if shared)",
        # Per-asset album attribution (scheduled/memory templates in combined mode).
        "album_name": "Source album name (combined-mode scheduled/memory only)",
        "album_url": "Source album URL — public share link if available, else internal album URL",
        "album_public_url": "Source album public share URL (empty if no public link)",
    }
    album_fields = {
        "name": "Collection/album name",
        "url": "Share URL",
        "public_url": "Public share link URL",
        "asset_count": "Total assets in collection",
        "photo_count": "Number of photos in the album",
        "video_count": "Number of videos in the album",
        "shared": "Whether collection is shared (boolean)",
        "owner": "Album owner display name",
    }
    scheduled_vars = {
        "date": "Current date string",
        "target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
    }

    return {
        "message_assets_added": {
            "description": "Notification when new assets are added to a collection",
            "variables": {
                **event_vars,
                "added_assets": "List of asset dicts (use {% for asset in added_assets %})",
                "common_date": "Shared date if all assets have the same date (formatted via date_only_format, empty otherwise)",
                "common_location": "Shared location if all assets are from the same place (e.g. 'Paris, France', empty otherwise)",
            },
            "asset_fields": asset_fields,
        },
        "message_assets_removed": {
            "description": "Notification when assets are removed from a collection",
            "variables": {**event_vars, "removed_assets": "List of removed asset IDs (strings)"},
        },
        "message_collection_renamed": {
            "description": "Notification when a collection is renamed",
            "variables": rename_vars,
        },
        "message_collection_deleted": {
            "description": "Notification when a collection is deleted",
            "variables": event_vars,
        },
        "message_sharing_changed": {
            "description": "Notification when sharing status changes",
            "variables": sharing_vars,
        },
        "periodic_summary_message": {
            "description": "Periodic summary of all tracked collections",
            "variables": {**scheduled_vars, "collections": "List of collection dicts (use {% for album in collections %})"},
            "album_fields": album_fields,
        },
        "scheduled_assets_message": {
            "description": "Scheduled asset delivery (daily photo picks)",
            "variables": {
                **scheduled_vars,
                "assets": "List of asset dicts (use {% for asset in assets %})",
                "album_name": "Source album name",
                "public_url": "Public share link URL for the source album (empty if none)",
                "asset_count": "Total assets in the source album",
                "photo_count": "Photos in the source album",
                "video_count": "Videos in the source album",
                "owner": "Source album owner",
            },
            "asset_fields": asset_fields,
        },
        "memory_mode_message": {
            "description": "\"On This Day\" memories from previous years",
            "variables": {
                **scheduled_vars,
                "assets": "List of asset dicts (use {% for asset in assets %})",
                "album_name": "Source album name (when rendered per-album)",
                "public_url": "Public share link URL for the source album (empty if none)",
            },
            "asset_fields": asset_fields,
        },
        # --- Generic Webhook slots ---
        **_webhook_variables(),
        # --- Gitea slots ---
        **_gitea_variables(),
        # --- Planka slots ---
        **_planka_variables(),
        # --- NUT (UPS) slots ---
        **_nut_variables(),
        # --- Home Assistant slots ---
        **_home_assistant_variables(),
        # --- Bridge self-monitoring slots ---
        **_bridge_self_variables(),
        # --- Scheduler slots ---
        "message_scheduled_message": {
            "description": "Notification for scheduled message events",
            "variables": {
                "schedule_name": "Name of the schedule that fired",
                "fire_count": "How many times this tracker has fired",
                "current_date": "Current date (formatted)",
                "current_time": "Current time (formatted)",
                "current_datetime": "Current date and time (formatted)",
                "weekday": "Day of the week (Monday..Sunday)",
                "timezone": "IANA timezone used for current_date/time",
            },
        },
    }


def _webhook_variables() -> dict:
    return {
        "message_webhook_received": {
            "description": "Incoming webhook event notification",
            "variables": {
                "service_name": "Provider instance name",
                "event_type_raw": "Raw event type from payload (or 'webhook_received')",
                "collection_name": "Collection extracted from payload via collection_path (or empty)",
                "source_ip": "IP address of the webhook sender",
                "raw_payload": "Full JSON payload as dict (use raw_payload.field or raw_payload | tojson)",
                "timestamp": "When the webhook was received",
                "target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
            },
        },
    }


def _gitea_variables() -> dict:
    common = {
        "sender": "Username who triggered the event",
        "sender_name": "Display name of the sender",
        "repo_name": "Repository full name (owner/repo)",
        "repo_url": "Repository URL",
    }
    return {
        "message_push": {
            "description": "Code pushed to repository",
            "variables": {**common, "branch": "Branch name", "commit_count": "Number of commits",
                          "compare_url": "Comparison URL", "commits": "List of commit dicts"},
        },
        "message_issue_opened": {
            "description": "Issue opened",
            "variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
                          "issue_url": "Issue URL", "issue_body": "Issue body text", "issue_labels": "Labels list"},
        },
        "message_issue_closed": {
            "description": "Issue closed",
            "variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
                          "issue_url": "Issue URL", "issue_state": "Issue state"},
        },
        "message_issue_commented": {
            "description": "Comment on issue",
            "variables": {**common, "issue_number": "Issue number", "issue_title": "Issue title",
                          "issue_url": "Issue URL", "comment_body": "Comment text",
                          "comment_url": "Comment URL", "comment_author": "Comment author"},
        },
        "message_pr_opened": {
            "description": "Pull request opened",
            "variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
                          "pr_url": "PR URL", "pr_body": "PR body text",
                          "pr_base": "Base branch", "pr_head": "Head branch", "pr_labels": "Labels list"},
        },
        "message_pr_closed": {
            "description": "Pull request closed",
            "variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
                          "pr_url": "PR URL", "pr_state": "PR state", "pr_merged": "Whether PR was merged"},
        },
        "message_pr_merged": {
            "description": "Pull request merged",
            "variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
                          "pr_url": "PR URL", "pr_base": "Base branch", "pr_head": "Head branch"},
        },
        "message_pr_commented": {
            "description": "Comment on pull request",
            "variables": {**common, "pr_number": "PR number", "pr_title": "PR title",
                          "pr_url": "PR URL", "comment_body": "Comment text",
                          "comment_url": "Comment URL", "comment_author": "Comment author"},
        },
        "message_release_published": {
            "description": "Release published",
            "variables": {**common, "release_tag": "Release tag", "release_name": "Release name",
                          "release_url": "Release URL", "release_body": "Release notes",
                          "release_draft": "Is draft (boolean)", "release_prerelease": "Is prerelease (boolean)"},
        },
    }


def _planka_variables() -> dict:
    common = {
        "sender": "Username who triggered the event",
        "sender_name": "Display name of the sender",
        "board_name": "Board name",
        "board_id": "Board ID",
        "board_url": "Board URL",
    }
    card = {**common, "card_name": "Card name", "card_id": "Card ID", "card_url": "Card URL"}
    return {
        "message_card_created": {"description": "Card created", "variables": {**card, "list_name": "List name", "card_description": "Card description"}},
        "message_card_updated": {"description": "Card updated", "variables": {**card, "card_description": "Card description", "card_due_date": "Due date"}},
        "message_card_moved": {"description": "Card moved between lists", "variables": {**card, "old_list_name": "Previous list", "new_list_name": "New list"}},
        "message_card_deleted": {"description": "Card deleted", "variables": card},
        "message_card_commented": {"description": "Comment added to card", "variables": {**card, "comment_text": "Comment text"}},
        "message_comment_updated": {"description": "Comment updated", "variables": {**card, "comment_text": "Updated comment text"}},
        "message_board_created": {"description": "Board created", "variables": common},
        "message_board_updated": {"description": "Board updated", "variables": common},
        "message_board_deleted": {"description": "Board deleted", "variables": common},
        "message_list_created": {"description": "List created", "variables": {**common, "list_name": "List name"}},
        "message_list_updated": {"description": "List updated", "variables": {**common, "list_name": "List name"}},
        "message_list_deleted": {"description": "List deleted", "variables": {**common, "list_name": "List name"}},
        "message_attachment_created": {"description": "Attachment added", "variables": {**card, "attachment_name": "Attachment filename"}},
        "message_card_label_added": {"description": "Label added to card", "variables": {**card, "label_name": "Label name", "label_color": "Label color"}},
        "message_task_completed": {"description": "Task completed", "variables": {**card, "task_name": "Task name", "task_completed": "Completed (boolean)"}},
    }


def _nut_variables() -> dict:
    common = {
        "ups_name": "UPS device name",
        "ups_model": "UPS hardware model",
        "ups_manufacturer": "UPS manufacturer",
        "battery_charge": "Battery charge percentage",
        "battery_runtime": "Estimated runtime (formatted)",
        "battery_runtime_seconds": "Estimated runtime in seconds",
        "ups_load": "UPS load percentage",
        "ups_status": "Raw status flags (e.g. OL, OB, LB)",
        "input_voltage": "Input voltage",
        "output_voltage": "Output voltage",
        "event_description": "Human-readable event description",
        "previous_status": "Previous UPS status flags",
    }
    return {
        "message_ups_online": {"description": "UPS back on mains power", "variables": common},
        "message_ups_on_battery": {"description": "UPS switched to battery", "variables": common},
        "message_ups_low_battery": {"description": "Battery critically low", "variables": common},
        "message_ups_battery_restored": {"description": "Battery charge recovered", "variables": common},
        "message_ups_comms_lost": {"description": "Communication with UPS lost", "variables": {"ups_name": common["ups_name"], "previous_status": common["previous_status"], "event_description": common["event_description"]}},
        "message_ups_comms_restored": {"description": "Communication restored", "variables": common},
        "message_ups_replace_battery": {"description": "Battery needs replacement", "variables": common},
        "message_ups_overload": {"description": "UPS load exceeded capacity", "variables": common},
    }


def _home_assistant_variables() -> dict:
    common = {
        "entity_id": "HA entity id (e.g. light.kitchen)",
        "friendly_name": "Human-readable entity name from attributes.friendly_name",
        "domain": "HA domain prefix (light, sensor, binary_sensor, ...)",
        "attributes": "Full attributes dict of the new state",
        "device_class": "Device class (motion, door, temperature, ...)",
        "unit_of_measurement": "Unit suffix for numeric sensors",
        "area": "Area name from the HA area registry (empty when not assigned)",
        "ha_event_type": "Raw HA event_type (state_changed, automation_triggered, ...)",
        "last_changed": "ISO timestamp of last state change",
        "last_updated": "ISO timestamp of last attribute or state update",
    }
    return {
        "message_ha_state_changed": {
            "description": "Entity state changed",
            "variables": {
                **common,
                "old_state": "Previous state string",
                "new_state": "New state string ('removed' if entity deleted)",
            },
        },
        "message_ha_automation_triggered": {
            "description": "Automation triggered",
            "variables": {
                "entity_id": common["entity_id"],
                "automation_name": "Automation name",
                "trigger_source": "Why the automation fired",
                "ha_event_type": common["ha_event_type"],
            },
        },
        "message_ha_service_called": {
            "description": "HA service called",
            "variables": {
                "service_called": "Qualified service name (e.g. light.turn_on)",
                "service_domain": "Service domain",
                "service_name": "Service name within domain",
                "service_data": "Service payload dict",
                "target_entity": "entity_id targeted by the call (comma-joined for multi-target)",
                "ha_event_type": common["ha_event_type"],
            },
        },
        "message_ha_event_fired": {
            "description": "Other HA event fired (catch-all)",
            "variables": {
                "ha_event_type": common["ha_event_type"],
                "event_data": "Raw event data dict (use {{ event_data | tojson }} to render)",
            },
        },
    }


def _bridge_self_variables() -> dict:
    common = {
        "failure_type": "Which condition fired (poll_failures, deferred_backlog, target_failures)",
        "subject_id": "Affected entity ID (tracker_id, target_id, or 0 for backlog)",
        "subject_name": "Human-readable name of the affected entity",
        "count": "Consecutive failure count or current backlog size",
        "threshold": "Configured threshold that was crossed",
        "last_error": "Last underlying error message (truncated)",
        "details": "Extra structured context dict (use {{ details | tojson }})",
    }
    return {
        "message_bridge_self_poll_failures": {
            "description": "Tracker poll failures crossed threshold",
            "variables": common,
        },
        "message_bridge_self_deferred_backlog": {
            "description": "Deferred dispatch backlog crossed threshold",
            "variables": common,
        },
        "message_bridge_self_target_failures": {
            "description": "Target send failures crossed threshold",
            "variables": common,
        },
    }


@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
    body: TemplateConfigCreate,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    config = TemplateConfig(
        user_id=user.id,
        provider_type=body.provider_type,
        name=body.name,
        description=body.description or "",
        icon=body.icon or "",
        date_format=body.date_format or "%d.%m.%Y, %H:%M UTC",
        date_only_format=body.date_only_format or "%d.%m.%Y",
    )
    session.add(config)
    await session.flush()  # get config.id
    if body.slots:
        await _save_slots(session, config.id, body.slots)
    await session.commit()
    await session.refresh(config)
    return await _response(session, config)


@router.get("/{config_id}")
async def get_config(
    config_id: int,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    config = await _get(session, config_id, user.id)
    return await _response(session, config)


@router.put("/{config_id}")
async def update_config(
    config_id: int,
    body: TemplateConfigUpdate,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    config = await _get(session, config_id, user.id)
    if config.user_id == 0 and user.role != "admin":
        raise HTTPException(status_code=403, detail="Cannot modify system default configs")
    for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
        if value is not None:
            setattr(config, field, value)
    session.add(config)
    if body.slots is not None:
        await _save_slots(session, config.id, body.slots)
    await session.commit()
    await session.refresh(config)
    return await _response(session, config)


@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
    config_id: int,
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    from .delete_protection import check_template_config, raise_if_used
    config = await _get(session, config_id, user.id)
    raise_if_used(await check_template_config(session, config.id), config.name)
    # Delete child slots first
    slot_result = await session.exec(
        select(TemplateSlot).where(TemplateSlot.config_id == config.id)
    )
    for slot in slot_result.all():
        await session.delete(slot)
    await session.delete(config)
    await session.commit()


@router.post("/{config_id}/preview")
async def preview_config(
    config_id: int,
    slot: str = "message_assets_added",
    locale: str = "en",
    user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_session),
):
    """Render a specific template slot with sample data."""
    config = await _get(session, config_id, user.id)
    slots = await _load_slots(session, config.id)
    locale_map = slots.get(slot, {})
    template_body = locale_map.get(locale) or locale_map.get("en", "")
    if not template_body:
        raise HTTPException(status_code=400, detail=f"Slot '{slot}' has no template")
    try:
        env = SandboxedEnvironment(autoescape=False)
        tmpl = env.from_string(template_body)
        rendered = tmpl.render(**_SAMPLE_CONTEXT)
        return {"slot": slot, "rendered": rendered}
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Template error: {e}")


class DateFormatPreviewRequest(BaseModel):
    date_format: str = "%d.%m.%Y, %H:%M UTC"
    date_only_format: str = "%d.%m.%Y"


@router.post("/preview-date-format")
async def preview_date_format(
    body: DateFormatPreviewRequest,
    user: User = Depends(get_current_user),
):
    """Preview what date/datetime formats look like with sample data."""
    from datetime import datetime, timezone
    sample_dt = datetime(2026, 3, 19, 14, 30, 0, tzinfo=timezone.utc)
    sample_date = datetime(2026, 3, 19)
    result: dict[str, str | None] = {}
    for key, fmt, sample in [
        ("date_format", body.date_format, sample_dt),
        ("date_only_format", body.date_only_format, sample_date),
    ]:
        try:
            result[key] = sample.strftime(fmt)
        except (ValueError, TypeError):
            result[key] = None
    return result


class PreviewRequest(BaseModel):
    template: str
    target_type: str = "telegram"  # telegram, webhook, email, discord, slack, ntfy, matrix
    date_format: str = "%d.%m.%Y, %H:%M UTC"
    date_only_format: str = "%d.%m.%Y"


@router.post("/preview-raw")
async def preview_raw(
    body: PreviewRequest,
    user: User = Depends(get_current_user),
):
    """Render arbitrary Jinja2 template text with sample data.

    Two-pass validation:
    1. Parse with default Undefined (catches syntax errors)
    2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
    """
    from datetime import datetime
    ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
           "date_format": body.date_format, "date_only_format": body.date_only_format}
    # Format common_date using the provided date_only_format
    try:
        ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
    except (ValueError, TypeError):
        ctx["common_date"] = "19.03.2026"
    return render_template_preview(body.template, ctx)
