@router.post("/{cid}/preview")
async def preview_campaign(cid: int, sample_size: int = Query(0),
                           db: AsyncSession = Depends(get_db),
                           user: User = Depends(get_current_user),
                           _: None = Depends(require_csrf)):
    """Preview: generate the campaign plan for a sample of recipients and render
    the actual messages (subject + HTML + substituted variables). NOTHING is sent."""
    c = await _get_own(db, user, cid)
    if c.status not in ("draft", "paused", "queued", "failed"):
        raise HTTPException(status_code=409, detail="Cannot preview a running campaign")

    size = sample_size or c.preview_sample_size or 5
    size = max(1, min(size, 20))

    if not c.recipient_list_id:
        raise HTTPException(status_code=400, detail="Campaign has no recipient list")
    rl = await db.get(RecipientList, c.recipient_list_id)
    if not rl or rl.user_id != user.id:
        raise HTTPException(status_code=400, detail="Recipient list not found")

    recipient_rows = (await db.execute(
        select(Recipient).where(Recipient.list_id == rl.id, Recipient.is_valid == True)  # noqa: E712
        .order_by(Recipient.id).limit(size))).scalars().all()
    if not recipient_rows:
        raise HTTPException(status_code=400, detail="Recipient list has no valid recipients")

    # materialize plan rows WITHOUT persisting CampaignRecipient entities
    result = await materialize_campaign(db, c, recipient_rows, preview=True,
                                          preview_size=size)
    plan = result.get("plan", [])

    # render actual message content per sample
    engine = VariableEngine(c.variable_config or {})
    out = []
    for item in plan:
        rec = next((r for r in recipient_rows if r.id == item["recipient_id"]), None)
        if not rec:
            continue
        tpl = await db.get(EmailTemplate, item["template_id"]) if item.get("template_id") else None
        subj = await db.get(SubjectLine, item["subject_id"]) if item.get("subject_id") else None
        g = GeneratedValues()
        g.invoice_number = item.get("invoice_number", "")
        g.amount = item.get("amount")
        g.shipping = item.get("shipping")
        g.invoice_date = item.get("invoice_date")
        g.due_date = item.get("due_date")
