            if self.usage[a.id] >= self.max_per_account:
                continue
            if a.cooldown_until and a.cooldown_until > utcnow():
                continue
            avail.append(a)
        return avail

    def next_account(self, account_id=None):
        """Return next account or None if all at limit/cooldown."""
        if account_id is not None:
            for a in self.accounts:
                if a.id == account_id:
                    self.usage[a.id] += 1
                    return a
        avail = self._available()
        if not avail:
            return None
        if self.strategy == "weighted":
            if not self._pool:
                pool = []
                for a in avail:
                    w = int(self.weights.get(str(a.id), 1))
                    pool.extend([a] * max(1, w))
                random.shuffle(pool)
                self._pool = pool
                self._pool_pos = 0
            while self._pool and self._pool_pos < len(self._pool):
                a = self._pool[self._pool_pos]
                self._pool_pos += 1
                if self.usage[a.id] < self.max_per_account and not (
                        a.cooldown_until and a.cooldown_until > utcnow()):
                    self.usage[a.id] += 1
                    return a
            self._pool = []
            return self.next_account()
        # sequential
        for _ in range(len(self.accounts)):
            a = self.accounts[self._seq_index % len(self.accounts)]
            self._seq_index += 1
            if self.usage[a.id] < self.max_per_account and not (
                    a.cooldown_until and a.cooldown_until > utcnow()):
                self.usage[a.id] += 1
                return a
        return None

    def status(self):
        return {"accounts": [{"id": a.id, "email": a.email, "usage": self.usage[a.id],
                              "limit": self.max_per_account} for a in self.accounts]}


class SubjectSelector:
    def __init__(self, subjects: List[SubjectLine], strategy: str = "round_robin"):
        self.subjects = subjects
        self.strategy = strategy if strategy in ("round_robin", "random", "fixed_first") else "round_robin"
        self._idx = 0

    def next_subject(self) -> Optional[SubjectLine]:
        if not self.subjects:
            return None
        if self.strategy == "random":
            return random.choice(self.subjects)
        if self.strategy == "fixed_first":
            return self.subjects[0]
        s = self.subjects[self._idx % len(self.subjects)]
        self._idx += 1
        return s


class TemplateSequencer:
    """Template 1 -> recipient 1, template 2 -> recipient 2 ... then repeat/stop/random."""
    def __init__(self, templates: List[EmailTemplate], mode: str = "repeat"):
        self.templates = templates
        self.mode = mode if mode in ("repeat", "stop", "random") else "repeat"
        self._idx = 0
        self._stopped = False

    def next_template(self) -> Optional[EmailTemplate]:
        if not self.templates:
            return None
        if self.mode == "random":
            return random.choice(self.templates)
===TAIL===
async def get_campaign_entities(db: AsyncSession, campaign: Campaign):
    """Load all entities a campaign references (subjects, templates, phones, accounts)."""
    subjects = []
    if campaign.subject_set_ids:
        for sid in campaign.subject_set_ids:
            s = await db.get(SubjectLine, sid)
            if s:
                subjects.append(s)
    templates = []
    if campaign.template_ids:
        for tid in campaign.template_ids:
            t = await db.get(EmailTemplate, tid)
            if t:
                templates.append(t)
    phones = (await db.execute(
        select(CompanyPhoneNumber).where(CompanyPhoneNumber.user_id == campaign.user_id,
                                         CompanyPhoneNumber.is_active == True))).scalars().all()  # noqa: E712
    accounts = []
    for aid in campaign.account_ids or []:
        acc = await db.get(GmailAccount, aid)
        if acc and acc.is_active and acc.status == "authorized":
            accounts.append(acc)
    return subjects, templates, phones, accounts


async def materialize_campaign(db: AsyncSession, campaign: Campaign, recipient_rows: Sequence[Recipient],
                               preview: bool = False) -> dict:
    """Create CampaignRecipient rows with generated values & assignments.

    This is the core deterministic plan: which subject/template/phone/account each recipient gets,
    plus generated invoice numbers (unique per campaign), amounts, shipping.
    """
    subjects, templates, phones, accounts = await get_campaign_entities(db, campaign)

    var_cfg = dict(campaign.variable_config or {})
    engine = VariableEngine(var_cfg)

    subj_sel = SubjectSelector(subjects, campaign.subject_rotation)
    tpl_seq = TemplateSequencer(templates, campaign.template_sequence_mode)
    phone_sel = PhoneSelector(phones, campaign.phone_selection)
    rot = RotationAssigner(accounts, campaign.rotation_strategy, campaign.rotation_weights,
                           campaign.max_per_account)

    used_invoice_numbers: set = set()
    rows: List[CampaignRecipient] = []
    plan = []
    seq = 0
    for r in recipient_rows:
        seq += 1
        cr = CampaignRecipient(
            campaign_id=campaign.id,
            recipient_id=r.id,
            email=r.email,
            seq=seq,
        )
        # generated values
        g = GeneratedValues()
        if var_cfg.get("invoice_date"):
            g.invoice_date = var_cfg["invoice_date"]
        if var_cfg.get("due_date"):
            g.due_date = var_cfg["due_date"]
        inv_num = ""
        tpl = tpl_seq.next_template()
        if tpl is None and templates:
            cr.status = "skipped"
            cr.last_error = "Template sequence exhausted (mode=stop)"
        body_uses_invoice = any("%RandomInvoiceNumber%" in (t.body_html + (t.body_text or "")) for t in templates) or (tpl and "%RandomInvoiceNumber%" in tpl.body_html)
        if body_uses_invoice or var_cfg.get("always_generate_invoice", True):
            inv_num = engine.generate_invoice_number(used_invoice_numbers)
            used_invoice_numbers.add(inv_num)
        cr.generated_invoice_number = inv_num
        g.invoice_number = inv_num

        if var_cfg.get("amount_enabled", True) or body_uses_invoice:
            g.amount = engine.generate_amount()
            cr.generated_amount = g.amount
        if var_cfg.get("shipping_enabled", True) or "%RandomShippingCharge%" in (tpl.body_html if tpl else ""):
            g.shipping = engine.generate_shipping()
            cr.generated_shipping = g.shipping

        subj = subj_sel.next_subject()
        cr.generated_subject_id = subj.id if subj else None
        cr.generated_template_id = tpl.id if tpl else None

        phone = phone_sel.next_phone()
