/*
 * useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa.
 * --------------------------------------------------
 * Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings,
 * eventos reais (useAgendaEvents), expande recorrências (useRecurrence),
 * resolve compromissos determinados (catálogo) e feriados, mantém o estado
 * do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam:
 *   onDialogSave / onDialogDelete    — CRUD com 7 cases (avulso, recorrente,
 *                                      somente_este, este_e_seguintes, todos,
 *                                      todos_sem_excecao)
 *   onUpdateSeriesEvent              — mudança de status numa ocorrência
 *   onEditSeriesOccurrence           — preview ao alternar editScope
 *   persistMoveOrResize              — drag/resize do FC com checagem de conflito
 *   onSelectTime                     — click-drag pra criar evento novo
 *   onEditEvento                     — popula dialogEventRow a partir do raw row
 *
 * Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH,
 * dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual
 * pra alimentar o AgendaEventDialog.
 *
 * Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles
 * no datesSet do FC (via provide/inject). O composable observa e refetcha.
 *
 * Os handlers exibem toasts (success/warn) — o composable assume que os
 * componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
 */
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';

import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';

// ─── Utilities puros (extraídos na Fase A da decomposição agenda) ───────────
// Mantidos em features/agenda/utils/ pra reuso em Rail/Clínica.
import {
    EVENTO_TIPO,
    EVENTO_TIPOS_VALIDOS,
    MAX_SESSION_MINUTES,
    normalizeEventoTipo,
    deriveEventoTipoForNewEvent,
    deriveTituloDefaultByTipo
} from '@/features/agenda/utils/eventoTipo';
import { pickDbFields } from '@/features/agenda/utils/dbFields';
import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
import { pickColor } from '@/features/agenda/utils/colors';

// ─── Service de billing (Fase B1 read-only + Fase B2 mutations) ────────────
import {
    computeSeriePrice as _computeSeriePrice,
    generateOccurrenceDates as _generateOccurrenceDates,
    loadStatusChangeContext,
    needsStatusConfirmDialog,
    applyStatusDecisions,
    createPackageContract as _createPackageContractService,
    materializeAndChargePerSession as _materializeAndChargePerSessionService
} from '@/features/agenda/services/agendaBilling.service';

function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
    // r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
    // (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
    const isOccurrence = !!r.is_occurrence;
    const pacNome = r.paciente_nome ?? r.patient_name ?? r.patients?.nome_completo ?? '';
    const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO);

    // Estado de pagamento — lido do _paymentStateMap (preenchido pelo
    // _reloadRange via bulk-query em financial_records). Default 'none'
    // pra ocorrências virtuais (sem id real ainda) e eventos sem record.
    // 'paid' | 'pending' | 'none'.
    // Fallback pra virtuais ou reais sem record: herda do contrato upfront
    // pago da série via _rulePaymentMap (chave = recurrence_id).
    let paymentState =
        (!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null;
    let paymentAmount =
        !isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null;
    if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) {
        paymentState = rulePaymentMap[r.recurrence_id].state;
        if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount;
    }
    if (!paymentState) paymentState = 'none';

    return {
        id: r.id,
        tipo,
        status: r.status || (isOccurrence ? 'agendado' : ''),
        titulo: r.titulo || r.titulo_custom || '',
        owner_id: r.owner_id ?? null,
        terapeuta_id: r.terapeuta_id ?? null,
        billing_contract_id: r.billing_contract_id ?? null,
        patient_id: r.patient_id ?? r.paciente_id ?? null,
        pacienteNome: pacNome,
        modalidade: r.modalidade || '',
        descricao: r.observacoes || '',
        color: pickColor(tipo, r.status, isOccurrence),
        label: pacNome || r.titulo || r.titulo_custom || (tipo === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : '—'),
        inicio_em: r.inicio_em,
        fim_em: r.fim_em,
        startH: isoToDecimalHour(r.inicio_em),
        endH: isoToDecimalHour(r.fim_em),
        dateKey: String(r.inicio_em || '').slice(0, 10),
        is_occurrence: isOccurrence,
        recurrence_id: r.recurrence_id ?? null,
        recurrence_date: r.recurrence_date ?? r.original_date ?? null,
        paymentState,
        paymentAmount,
        // Info do contrato (saldo/upfront) — injetado quando a série tem
        // billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M".
        contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null,
        price: r.price != null ? Number(r.price) : null,
        // insurance_value: pra convênio, o valor cobrado mora aqui (não em
        // price). Popover e Resumo usam fallback `price ?? insurance_value`
        // pra mostrar o valor da cobrança independente do tipo.
        insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
        _raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
    };
}

// ───────────────────────────────────────────────────────────────────────────
// Composable principal
// ───────────────────────────────────────────────────────────────────────────
// Símbolo de injeção pra MelissaAgenda recuperar o composable do MelissaLayout
// sem prop drilling. Pattern: MelissaLayout chama `provide(MELISSA_AGENDA_KEY, m)`
// e MelissaAgenda chama `inject(MELISSA_AGENDA_KEY)`.
export const MELISSA_AGENDA_KEY = Symbol('melissaAgenda');

export function useMelissaAgenda() {
    const toast = useToast();
    const confirm = useConfirm();
    const tenantStore = useTenantStore();

    // Refs do range visível — mutados pela MelissaAgenda no datesSet do FC.
    // Início default: semana ao redor de hoje (FC sobrescreve no mount).
    const viewStart = ref(new Date());
    const viewEnd = ref(new Date());
    {
        const hoje = new Date();
        const dow = hoje.getDay();
        const diff = dow === 0 ? -6 : 1 - dow;
        const segunda = new Date(hoje);
        segunda.setDate(hoje.getDate() + diff);
        segunda.setHours(0, 0, 0, 0);
        const domingoNext = new Date(segunda);
        domingoNext.setDate(segunda.getDate() + 7);
        viewStart.value = segunda;
        viewEnd.value = domingoNext;
    }

    const clinicTenantId = computed(
        () => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
    );

    // ── Settings + workRules ────────────────────────────────────
    // cache: stale-while-revalidate via melissaCacheStore — abertura
    // subsequente da Agenda na mesma sessão usa cache instantâneo.
    const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true });
    // _bootUid: pegado em paralelo no mount via supabase.auth.getUser().
    // Sem isso, ownerId ficava null até loadSettings completar (~300ms),
    // bloqueando o primeiro fetch dos eventos. Como owner_id da agenda
    // é literalmente o uid do user logado, podemos resolver imediato.
    const _bootUid = ref('');
    const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || '');

    // ── Eventos reais (CRUD) ────────────────────────────────────
    const {
        rows,
        loading: eventsLoading,
        error: eventsError,
        loadMyRange,
        create,
        update,
        remove
    } = useAgendaEvents();

    // ── Recorrência ─────────────────────────────────────────────
    const {
        loadAndExpand,
        createRule,
        updateRule,
        cancelRule,
        splitRuleAt,
        cancelRuleFrom,
        upsertException
    } = useRecurrence();
    const _occurrenceRows = ref([]);

    // ── Compromissos determinados (catálogo) ────────────────────
    const { rows: determinedCommitments, load: loadDeterminedCommitments } = useDeterminedCommitments(clinicTenantId);

    // Adapta pro shape que o AgendaEventDialog espera (mesma lógica da
    // AgendaTerapeutaPage — prioridade pra "Sessão" primeiro)
    const commitmentOptions = computed(() => {
        const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [];
        const priority = new Map([
            ['session', 0], ['class', 1], ['study', 2],
            ['reading', 3], ['supervision', 4], ['content_creation', 5]
        ]);
        return [...list]
            .filter((i) => i?.id && i?.active !== false)
            .sort((a, b) => {
                const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99;
                const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99;
                if (pa !== pb) return pa - pb;
                return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
            })
            .map((i) => ({
                id: i.id,
                created_by: i.created_by ?? null,
                name: String(i.name || '').trim() || 'Sem nome',
                description: i.description || '',
                native_key: i.native_key || null,
                is_native: !!i.is_native,
                is_locked: !!i.is_locked,
                active: i.active !== false,
                bg_color: i.bg_color || null,
                text_color: i.text_color || null,
                fields: Array.isArray(i.determined_commitment_fields)
                    ? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
                    : []
            }));
    });

    // ── Feriados + commitment services ──────────────────────────
    // Instância única de useFeriados — antes MelissaAgenda.vue criava
    // sua própria também, fazendo dupla requisição de feriados municipais
    // toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do
    // composable injetado (M.feriadosAno, M.loadFeriadosBase, etc).
    const {
        todos: feriados,
        fcEvents: feriadoFcEvents,
        load: loadFeriadosBase,
        ano: feriadosAno
    } = useFeriados({ cache: true });
    const { saveRuleItems, propagateToSerie } = useCommitmentServices();

    // Bloqueios — renderizados como background events cinza no FullCalendar
    // (entidade própria, distinta de agenda_eventos). Carregados via range
    // visível + recorrentes; reativo a viewStart/viewEnd via _reloadRange.
    const { bloqueios, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
    const bloqueioFcEvents = computed(() => {
        const s = viewStart.value;
        const e = viewEnd.value;
        if (!s || !e) return [];
        return buildBloqueioEvents(s, e);
    });

    // Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
    // Cobre bloqueios dia-inteiro (sem hora), com janela horária e recorrentes
    // semanais (via dia_semana). Usado pra avisar o terapeuta quando ele tenta
    // criar sessão em cima de bloqueio próprio — não impede, só sinaliza.
    function bloqueioCobrindo(start, end) {
        const arr = bloqueios?.value || [];
        if (!arr.length || !start) return null;
        const dStart = start instanceof Date ? start : new Date(start);
        const dEnd = end instanceof Date ? end : new Date(end || start);
        const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
        const dow = dStart.getDay();
        const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
        const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
        const parseHM = (s) => {
            if (!s) return null;
            const [h, m] = String(s).split(':').map(Number);
            return Number.isFinite(h) ? h * 60 + (m || 0) : null;
        };
        for (const b of arr) {
            if (!b) continue;
            if (b.recorrente && b.dia_semana != null) {
                if (Number(b.dia_semana) !== dow) continue;
            } else {
                const di = b.data_inicio;
                const df = b.data_fim || b.data_inicio;
                if (!di) continue;
                if (isoDay < di || isoDay > df) continue;
            }
            const bhi = parseHM(b.hora_inicio);
            const bhf = parseHM(b.hora_fim);
            if (bhi == null || bhf == null) return b;
            if (hhmmStart < bhf && hhmmEnd > bhi) return b;
        }
        return null;
    }

    // ── Linhas combinadas (real + virtual) ──────────────────────
    const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]);

    // Map de estado de pagamento por agenda_evento_id, preenchido pelo
    // _reloadRange via bulk-query em financial_records. Permite renderizar
    // badge "$ pendente" no FC e linha "A receber" no popover sem ter que
    // queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'.
    const _paymentStateMap = ref({});
    // Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo
    // popover e Resumo do dialog quando paymentState='paid' pra mostrar o
    // valor REAL pago (vs evento.price que pode ter sido editado depois).
    const _paymentAmountMap = ref({});
    // Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não
    // têm id real e portanto não entram em _paymentStateMap), normalize lê
    // daqui o estado herdado do contrato upfront pago da série.
    const _rulePaymentMap = ref({});
    // Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice}
    // — info do billing_contract da série pra exibir no popover.
    const _ruleContractMap = ref({});

    // ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
    const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value)));

    // ── Eventos do FC original (se precisar — AgendaEventDialog quer
    //    `allEvents` no shape FC pra checar conflitos) ─────────
    const allEventsForDialog = computed(() =>
        allRows.value.map((r) => ({
            id: r.id,
            start: r.inicio_em,
            end: r.fim_em,
            extendedProps: { ...r }
        }))
    );

    // ── Dialog state ────────────────────────────────────────────
    const dialogOpen = ref(false);
    const dialogEventRow = ref(null);
    const dialogStartISO = ref('');
    const dialogEndISO = ref('');
    // Aviso quando o slot escolhido cai em cima de um bloqueio próprio.
    // Renderizado pelo AgendaEventDialog no topo do step 1 (Message warn).
    // null = sem aviso; { titulo } = aviso ativo.
    const dialogBlockOverlap = ref(null);

    // Segundo dialog (empilhado por cima do principal) — usado pra editar
    // uma OCORRÊNCIA específica de uma série. Acionado pelo botão "Editar"
    // de cada pill na lista "Recorrências Aplicadas" do AgendaEventDialog.
    // Adicionado 2026-05-11 — espelho pra Rail/Clínica está pendente.
    const occDialogOpen = ref(false);
    const occDialogEventRow = ref(null);
    const occDialogStartISO = ref('');
    const occDialogEndISO = ref('');

    // serieRefreshTick: bump quando o 2º dialog (ocorrencia) faz save com
    // sucesso. AgendaEventDialog do dialog PAI observa essa prop e re-roda
    // loadSerieEvents pra atualizar a lista de pills "Recorrencias Aplicadas".
    // Sem isso, pills ficavam com status stale ate fechar/reabrir o pai. 2026-05-12.
    const serieRefreshTick = ref(0);

    // Status change confirm dialog (Fase 5, 2026-05-14) — aparece quando
    // user muda status pra realizado/faltou/cancelado e há decisão a tomar
    // (regra de exceção, pacote saldo, pending record).
    const statusDialogOpen = ref(false);
    const statusDialogProps = ref({});
    // Resolver guardado fora do ref pra não vazar pro template via reactivity.
    let _statusDialogResolve = null;
    function _openStatusDialog(propsObj) {
        return new Promise((resolve) => {
            statusDialogProps.value = propsObj;
            statusDialogOpen.value = true;
            _statusDialogResolve = resolve;
        });
    }
    function onStatusDialogConfirm(decision) {
        if (_statusDialogResolve) _statusDialogResolve(decision);
        _statusDialogResolve = null;
    }
    function onStatusDialogCancel() {
        // PrimeVue Dialog dispara update:visible(false) ao fechar via X/Esc;
        // tratar isso aqui ainda — resolve com null pra cancelar.
        if (_statusDialogResolve) _statusDialogResolve(null);
        _statusDialogResolve = null;
    }

    // Bloqueio dialog (modo: 'horario' | 'periodo' | 'dia' | 'feriados')
    const bloqueioDialogOpen = ref(false);
    const bloqueioMode = ref('horario');
    function openBloqueioDialog(mode) {
        if (!ownerId.value) {
            toast.add({
                severity: 'warn',
                summary: 'Agenda',
                detail: 'Aguarde carregar as configurações da agenda.',
                life: 3000
            });
            return;
        }
        bloqueioMode.value = mode || 'horario';
        bloqueioDialogOpen.value = true;
    }

    // ── Refetch (com merge real+virtual) ────────────────────────
    async function _reloadRange() {
        const s = viewStart.value;
        const e = viewEnd.value;
        if (!s || !e) return;

        // Espera ownerId E tenant — qualquer um faltando significa boot
        // ainda em curso (auth/tenantStore/settings async). Watcher one-shot
        // re-dispara assim que o último ficar disponível, sem polling.
        if (!ownerId.value || !clinicTenantId.value) {
            const unwatch = watch(
                () => [ownerId.value, clinicTenantId.value],
                ([uid, tid]) => {
                    if (!uid || !tid) return;
                    unwatch();
                    _reloadRange();
                }
            );
            return;
        }

        const start = new Date(s);
        const end = new Date(e);
        const tid = clinicTenantId.value;

        // Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza
        // assim que esse await resolve (o user já vê as sessões agendadas).
        await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);

        // Etapa 2: ocorrências virtuais (regras de recorrência expandidas).
        // Continuamos awaitando porque saveRule/cancel dependem do estado
        // final estar pronto pra UI consistente, mas a janela visual onde
        // o usuário vê só eventos reais é a metade do tempo de antes.
        const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
        _occurrenceRows.value = merged.filter((r) => r.is_occurrence);

        // Etapa 3: bloqueios (background events). Não bloqueia render do
        // calendário — load assíncrono em paralelo. bloqueioFcEvents é computed
        // e re-renderiza quando bloqueios.value atualizar.
        loadBloqueios(ownerId.value, start, end);

        // Etapa 4: estado de pagamento — bulk-query 1x pra todos os eventos
        // reais visíveis. Annotação 'paid' | 'pending' fica no _paymentStateMap;
        // o computed `eventos` re-corre normalizeForMelissa quando o map muda.
        // Não bloqueia render — eventos aparecem sem badge até este await
        // resolver (default 'none').
        const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
        // Inicializa maps SEMPRE (não condicional a realIds.length>0) — em
        // semanas onde só há virtuais (sem eventos reais), ainda precisamos
        // rodar a propagação cross-week pra ruleMap.
        const map = {};
        const amountMap = {};
        const ruleMap = {};
        const ruleContractMap = {};
        // Filtra cancelados: cobrança cancelada não deve manter
        // paymentState='pending' (badge $ residual). Tratamos cancelled
        // como "sem cobrança ativa" → cai pro default 'none'.
        if (realIds.length) {
            const { data: recs } = await tenantDb().from('financial_records')
                .select('agenda_evento_id, paid_at, status, amount, final_amount')
                .in('agenda_evento_id', realIds)
                .neq('status', 'cancelled');
            for (const id of realIds) map[id] = 'none';
            for (const rec of recs || []) {
                const eid = rec.agenda_evento_id;
                if (!eid) continue;
                if (rec.paid_at) {
                    // Paid override: se QUALQUER record dessa sessão está paid,
                    // consideramos cobrança honrada. Filhos (multas/taxas) que
                    // venham pendentes não revertem esse estado pro badge.
                    map[eid] = 'paid';
                    amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
                } else if (map[eid] !== 'paid') {
                    map[eid] = 'pending';
                    if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
                }
            }
        }

        // Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais
        // na view), pra que virtuais isoladas em semanas futuras herdem o
        // estado do contrato cross-week. Antes estava dentro de
        // `if (realIds.length)` e falhava em semanas só com virtuais.
        //
        // Quando a 1ª materializada
        // de uma série tem record (paid OU pending), TODAS as ocorrências
            // do mesmo recurrence_id devem herdar o mesmo estado (cobertas
            // pelo contrato — cobrança única do pacote inteiro).
            // Funciona pra real rows; virtuais ficam por loadOccFinancialRecord
            // no occurrenceMode (não passam por este bulk).
            // Estratégia: pega rules dos eventos com record OU dos eventos
            // visíveis que têm recurrence_id (cross-week — record do pacote
            // pode estar em outra semana), busca contratos upfront ativos
            // pros pacientes, propaga estado pra siblings no view atual.
            try {
                // 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem
                //    a uma série. Antes só usávamos eventos COM record paid/pending,
                //    o que falhava cross-week (record do pacote pode estar em
                //    outra semana). Agora propagação cobre Ana 2/3/4 mesmo
                //    quando o record está só no Ana 1.
                const ruleIdsInView = new Set();
                for (const r of rows.value || []) {
                    if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
                }
                // Virtuais expandidas (que vêm depois) também trazem rules:
                for (const r of _occurrenceRows.value || []) {
                    if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
                }
                if (ruleIdsInView.size) {
                    // 2) Acha patient_id direto das rules (fonte autoritativa,
                    //    funciona até quando rule não tem nenhum evento
                    //    materializado — caso pacote saldo recém-criado).
                    const { data: rulesData } = await tenantDb().from('recurrence_rules')
                        .select('id, patient_id')
                        .in('id', [...ruleIdsInView]);
                    const rulePatientFromRule = new Map();
                    for (const r of rulesData || []) {
                        if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id);
                    }

                    // 3) Acha eventos (em qualquer semana) das rules em view +
                    //    seus records paid/pending pra detectar estado.
                    const { data: allRuleEvents } = await tenantDb().from('agenda_eventos')
                        .select('id, recurrence_id, patient_id')
                        .in('recurrence_id', [...ruleIdsInView]);
                    const ruleEventIds = (allRuleEvents || []).map((e) => e.id);
                    let ruleRecords = [];
                    if (ruleEventIds.length) {
                        const { data: rr } = await tenantDb().from('financial_records')
                            .select('agenda_evento_id, paid_at, status')
                            .in('agenda_evento_id', ruleEventIds)
                            .neq('status', 'cancelled');
                        ruleRecords = rr || [];
                    }
                    // Mapeia: rule_id → state (paid > pending > none) + patient
                    const evIdToRule = new Map();
                    const evIdToPatient = new Map();
                    for (const e of allRuleEvents || []) {
                        evIdToRule.set(e.id, e.recurrence_id);
                        evIdToPatient.set(e.id, e.patient_id);
                    }
                    const ruleToPatient = new Map();
                    const ruleToState = new Map();
                    for (const rec of ruleRecords) {
                        const rid = evIdToRule.get(rec.agenda_evento_id);
                        const pid = evIdToPatient.get(rec.agenda_evento_id);
                        if (!rid || !pid) continue;
                        ruleToPatient.set(rid, pid);
                        const newState = rec.paid_at ? 'paid' : 'pending';
                        const existing = ruleToState.get(rid);
                        if (existing !== 'paid') ruleToState.set(rid, newState);
                    }
                    // Busca contratos ativos pra TODOS pacientes envolvidos
                    // (saldo OU upfront — ambos exibem info no popover).
                    // Query única com todos campos necessários. Usa
                    // rulePatientFromRule (fonte autoritativa) pra cobrir
                    // saldo sem records (não passa por ruleToPatient).
                    const allPatientIds = [...new Set(rulePatientFromRule.values())];
                    let activePackages = [];
                    if (allPatientIds.length) {
                        const { data: contracts } = await tenantDb().from('billing_contracts')
                            .select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price')
                            .in('patient_id', allPatientIds);
                        activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
                    }
                    // Index por patient_id pra lookup rápido
                    const contractByPatient = new Map();
                    for (const c of activePackages) contractByPatient.set(c.patient_id, c);
                    // Popula ruleContractMap pra TODAS as rules em view com
                    // contrato ativo (saldo + upfront, com OU sem records).
                    for (const [rid, pid] of rulePatientFromRule.entries()) {
                        const c = contractByPatient.get(pid);
                        if (c) {
                            ruleContractMap[rid] = {
                                id: c.id,
                                style: c.charging_style || 'upfront',
                                totalSessions: c.total_sessions || 0,
                                sessionsUsed: c.sessions_used || 0,
                                packagePrice: Number(c.package_price || 0)
                            };
                        }
                    }
                    if (ruleToPatient.size) {
                        // NULL charging_style → assume upfront (default histórico
                        // antes da migration 20260514000003). Pra dados antigos
                        // sem a coluna preenchida, evita virtuais ficarem sem
                        // propagação. 'saldo' explícito mantém siblings 'none'.
                        const upfrontPatients = new Set(
                            activePackages
                                .filter((c) => c.charging_style === 'upfront' || c.charging_style == null)
                                .map((c) => c.patient_id)
                        );
                        const ruleIdsToPropagate = [...ruleToPatient.entries()]
                            .filter(([, pid]) => upfrontPatients.has(pid))
                            .map(([rid]) => rid);
                        if (ruleIdsToPropagate.length) {
                            const { data: siblings } = await tenantDb().from('agenda_eventos')
                                .select('id, recurrence_id')
                                .in('recurrence_id', ruleIdsToPropagate);
                            // Reusa contractByPatient da query unificada acima
                            // (antes havia uma 2ª query redundante pro mesmo dado).
                            const contractPriceByPatient = new Map();
                            for (const c of activePackages) {
                                if (c.charging_style === 'upfront' || c.charging_style == null) {
                                    contractPriceByPatient.set(c.patient_id, c.package_price);
                                }
                            }
                            for (const s of siblings || []) {
                                if (map[s.id] !== undefined) {
                                    const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending';
                                    // Não rebaixa: se sibling já está paid
                                    // (record próprio paid), mantém.
                                    if (map[s.id] !== 'paid') map[s.id] = stateToPropagate;
                                    const pid = ruleToPatient.get(s.recurrence_id);
                                    const price = contractPriceByPatient.get(pid);
                                    if (price != null) amountMap[s.id] = price;
                                }
                            }
                            // Populate _rulePaymentMap pra que VIRTUAIS (ainda
                            // não materializadas) também herdem o estado ao
                            // passar pelo normalize.
                            for (const rid of ruleIdsToPropagate) {
                                const pid = ruleToPatient.get(rid);
                                const price = contractPriceByPatient.get(pid);
                                const state = ruleToState.get(rid) || 'pending';
                                ruleMap[rid] = { state, amount: price ?? null };
                            }
                        }
                    }
                }
            } catch (e) {
                console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message);
            }

        _paymentStateMap.value = map;
        _paymentAmountMap.value = amountMap;
        _rulePaymentMap.value = ruleMap;
        _ruleContractMap.value = ruleContractMap;
    }

    async function refetch() {
        await _reloadRange();
    }

    // ── Inicialização ───────────────────────────────────────────
    // Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo
    // tempo. Antes era serial (loadSettings precisava terminar pra ownerId
    // ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms
    // de waterfall antes da primeira query de eventos sair.
    onMounted(() => {
        // 1) Resolve o uid o quanto antes — destrava _reloadRange.
        //    getSession() lê do storage local (fast path, <10ms);
        //    getUser() faria round-trip pro auth server. Fallback pro
        //    getUser só se a sessão ainda não estiver no storage.
        supabase.auth.getSession()
            .then(({ data }) => {
                const uid = data?.session?.user?.id;
                if (uid) {
                    _bootUid.value = uid;
                } else {
                    // Cold start sem sessão hidratada — fallback pro round-trip.
                    return supabase.auth.getUser().then(({ data: u }) => {
                        if (u?.user?.id) _bootUid.value = u.user.id;
                    });
                }
            })
            .catch(() => { /* noop — settings ainda pode resolver */ });

        // 2) Garante que o tenant está hidratado (idempotente — se já
        //    estiver carregado, retorna imediato).
        if (typeof tenantStore.ensureLoaded === 'function') {
            tenantStore.ensureLoaded().catch(() => {});
        }

        // 3) Settings em paralelo (não bloqueia mais nada)
        loadSettings();
    });

    // Refetch settings + workRules quando o user salva jornada/ritmo/online
    // em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
    // do resumo continuaria mostrando o range antigo até reload da página.
    function _onSettingsSaved() {
        loadSettings();
    }
    onMounted(() => {
        window.addEventListener('agenda:settings-saved', _onSettingsSaved);
    });
    onBeforeUnmount(() => {
        window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
    });

    // Commitments + feriados dependem do tenant. Em refresh "frio", o
    // tenantStore ainda não terminou de hidratar quando o composable
    // monta, e clinicTenantId fica null. loadDeterminedCommitments faz
    // bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
    // — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
    // AgendaEventDialog. Watch com immediate: true dispara já se o tenant
    // estiver pronto, ou no momento exato em que ele aparecer.
    watch(
        clinicTenantId,
        async (tid) => {
            if (!tid) return;
            await loadDeterminedCommitments();
            await loadFeriadosBase(tid);
        },
        { immediate: true }
    );

    // Reload quando o range visível muda. _reloadRange já tem guard
    // interno pra esperar uid+tenant (one-shot watcher) — sem necessidade
    // de outro watch global em ownerId, que disparava _reloadRange duplicado.
    watch([viewStart, viewEnd], _reloadRange);

    // ──────────────────────────────────────────────────────────
    // Handlers — populados na Stage 2
    // ──────────────────────────────────────────────────────────
    const _stageTwo = {}; // placeholder pra próxima passada
    Object.assign(_stageTwo, _buildHandlers({
        toast, confirm, supabase,
        ownerId, clinicTenantId, settings,
        rows, allRows, eventsError,
        create, update, remove,
        loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException,
        saveRuleItems, propagateToSerie,
        dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
        occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
        serieRefreshTick,
        // Fase 5: dialog de status change vive no escopo do composable
        // (refs ficam aqui pra expor pro template); _buildHandlers precisa
        // do opener pra abrir e aguardar decisão antes de aplicar.
        _openStatusDialog,
        _reloadRange,
        bloqueioCobrindo,
        dialogBlockOverlap
    }));

    return {
        // View range (mutado pela MelissaAgenda via FC.datesSet)
        viewStart,
        viewEnd,

        // Eventos pro FC
        eventos,
        rawRows: allRows,
        loading: eventsLoading,
        refetch,

        // Dialog state
        dialogOpen,
        dialogEventRow,
        dialogStartISO,
        dialogEndISO,
        dialogBlockOverlap,

        // 2º dialog (edit ocorrência da série, empilhado por cima)
        occDialogOpen,
        occDialogEventRow,
        occDialogStartISO,
        occDialogEndISO,
        serieRefreshTick,

        // Bloqueio dialog
        bloqueioDialogOpen,
        bloqueioMode,
        openBloqueioDialog,

        // Status change confirm dialog (Fase 5)
        statusDialogOpen,
        statusDialogProps,
        onStatusDialogConfirm,
        onStatusDialogCancel,

        // Settings + commitments + feriados (props pro AgendaEventDialog)
        settings,
        workRules,
        ownerId,
        clinicTenantId,
        commitmentOptions,
        feriados,
        feriadoFcEvents,
        feriadosAno,
        loadFeriadosBase,
        allEventsForDialog,

        // Bloqueios (background events cinza no FC)
        bloqueios,
        bloqueioFcEvents,

        // Handlers
        ..._stageTwo
    };
}

// ───────────────────────────────────────────────────────────────────────────
// Handlers — extraídos numa função à parte só por organização (mantém o
// composable principal legível). Stage 2 preenche as funções abaixo.
// ───────────────────────────────────────────────────────────────────────────
function _buildHandlers(deps) {
    // Só desempacota o que os handlers desta função usam diretamente —
    // _buildOnDialogSave/_buildOnDialogDelete recebem `deps` completo.
    const {
        toast, confirm,
        ownerId, clinicTenantId, settings,
        allRows, eventsError,
        create, update,
        dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
        occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
        _openStatusDialog,
        _reloadRange,
        bloqueioCobrindo,
        dialogBlockOverlap
    } = deps;

    // Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo
    // "16h" / "15:15h" / "27/04").
    function _fmtH(d) {
        if (!d) return '?';
        const h = d.getHours();
        const m = d.getMinutes();
        return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
    }
    function _fmtD(d) {
        if (!d) return '';
        return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
    }
    // Escape HTML — usado quando a mensagem do confirm tem partes
    // controladas pelo usuário (nome do paciente) e v-html no slot.
    function _esc(s) {
        return String(s ?? '')
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

    // ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ──
    // Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado
    // pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick).
    function onEditEvento(rawRow) {
        if (!rawRow) return;
        dialogEventRow.value = {
            id: rawRow.is_occurrence ? null : rawRow.id || null,
            owner_id: rawRow.owner_id ?? ownerId.value,
            terapeuta_id: rawRow.terapeuta_id ?? null,
            paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null,
            paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null,
            paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null,
            paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null,
            tipo: rawRow.tipo || 'sessao',
            status: rawRow.status,
            titulo: rawRow.titulo,
            observacoes: rawRow.observacoes ?? null,
            visibility_scope: rawRow.visibility_scope ?? 'public',
            inicio_em: rawRow.inicio_em,
            fim_em: rawRow.fim_em,
            modalidade: rawRow.modalidade ?? null,
            determined_commitment_id: rawRow.determined_commitment_id ?? null,
            titulo_custom: rawRow.titulo_custom ?? null,
            extra_fields: rawRow.extra_fields ?? null,
            price: rawRow.price != null ? Number(rawRow.price) : null,
            insurance_plan_id: rawRow.insurance_plan_id ?? null,
            insurance_guide_number: rawRow.insurance_guide_number ?? null,
            insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null,
            insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null,
            // recorrência
            recurrence_id: rawRow.recurrence_id ?? null,
            original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null,
            is_occurrence: !!rawRow.is_occurrence,
            exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null,
            // legado
            serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null,
            serie_dia_semana: rawRow.serie_dia_semana ?? null,
            serie_hora: rawRow.serie_hora ?? null
        };
        dialogStartISO.value = '';
        dialogEndISO.value = '';
        dialogBlockOverlap.value = null;
        dialogOpen.value = true;
    }

    // ── onCreateEvento — botão "+ Agendar" sem seleção no FC ──
    // Espelha AgendaTerapeutaPage.onCreateFromButton:1423 — usa o dia
    // visualizado + hora atual como defaults razoáveis.
    function onCreateEvento() {
        if (!ownerId.value) {
            toast.add({
                severity: 'warn',
                summary: 'Agenda',
                detail: 'Aguarde carregar as configurações da agenda.',
                life: 3000
            });
            return;
        }
        const durMin =
            settings.value?.session_duration_min ??
            settings.value?.duracao_padrao_minutos ??
            50;
        const base = new Date();
        // Arredonda pra próximo slot de 15min — UX mais limpa que minuto solto.
        base.setSeconds(0, 0);
        const remainder = base.getMinutes() % 15;
        if (remainder !== 0) {
            base.setMinutes(base.getMinutes() + (15 - remainder));
        }

        dialogEventRow.value = {
            owner_id: ownerId.value,
            terapeuta_id: null,
            paciente_id: null,
            tipo: EVENTO_TIPO.SESSAO,
            status: 'agendado',
            titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
            observacoes: null,
            visibility_scope: 'public',
            determined_commitment_id: null
        };
        dialogStartISO.value = base.toISOString();
        dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
        dialogBlockOverlap.value = null;
        dialogOpen.value = true;
    }

    // ── onCreateEventoForPatient — abre o AgendaEventDialog com paciente
    // pre-selecionado. Usado pelo MelissaPaciente quando o user clica
    // "Agendar" na sidebar Acoes Rapidas. Mesma logica de onCreateEvento
    // (defaults razoaveis: hoje proximo slot 15min, duracao default), so
    // que injeta paciente_id no dialogEventRow.
    function onCreateEventoForPatient(patientId) {
        if (!ownerId.value) {
            toast.add({
                severity: 'warn',
                summary: 'Agenda',
                detail: 'Aguarde carregar as configurações da agenda.',
                life: 3000
            });
            return;
        }
        const durMin =
            settings.value?.session_duration_min ??
            settings.value?.duracao_padrao_minutos ??
            50;
        const base = new Date();
        base.setSeconds(0, 0);
        const remainder = base.getMinutes() % 15;
        if (remainder !== 0) {
            base.setMinutes(base.getMinutes() + (15 - remainder));
        }

        dialogEventRow.value = {
            owner_id: ownerId.value,
            terapeuta_id: null,
            paciente_id: patientId ? String(patientId) : null,
            tipo: EVENTO_TIPO.SESSAO,
            status: 'agendado',
            titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
            observacoes: null,
            visibility_scope: 'public',
            determined_commitment_id: null
        };
        dialogStartISO.value = base.toISOString();
        dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
        dialogBlockOverlap.value = null;
        dialogOpen.value = true;
    }

    // ── onSelectTime — click-drag no FC pra criar evento novo ──
    // Dinâmica de duração:
    //   click sem drag       → settings.session_duration_min (default 50)
    //   drag ≤ default        → default (mínimo da configuração)
    //   drag entre default e MAX_SESSION_MINUTES → respeita o drag (snap em
    //                          15min já feito pelo FC via snapDuration)
    //   drag > MAX            → capa em MAX (regra de negócio Melissa: 2h
    //                          é o slot máximo permitido)
    function onSelectTime(selection) {
        const defaultDur =
            settings.value?.session_duration_min ??
            settings.value?.duracao_padrao_minutos ??
            50;

        const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start);
        const rawEnd = selection.end
            ? (selection.end instanceof Date ? selection.end : new Date(selection.end))
            : null;
        const dragMin = rawEnd ? Math.round((rawEnd.getTime() - rawStart.getTime()) / 60000) : 0;

        const durMin =
            dragMin <= defaultDur ? defaultDur
            : dragMin <= MAX_SESSION_MINUTES ? dragMin
            : MAX_SESSION_MINUTES;

        const startISO = rawStart.toISOString();
        const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();

        // Aviso de bloqueio — não veta criação (só agendador público veta),
        // mas sinaliza pro terapeuta que tá agendando em cima de bloqueio.
        // Renderizado como Message no topo do step 1 do AgendaEventDialog
        // (toast antigo ficava atrás do overlay do dialog).
        const bloqHit = bloqueioCobrindo(rawStart, new Date(rawStart.getTime() + durMin * 60000));
        dialogBlockOverlap.value = bloqHit ? { titulo: bloqHit.titulo || 'Bloqueio' } : null;

        dialogEventRow.value = {
            owner_id: ownerId.value,
            terapeuta_id: null,
            paciente_id: null,
            tipo: EVENTO_TIPO.SESSAO,
            status: 'agendado',
            titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
            observacoes: null,
            visibility_scope: 'public',
            determined_commitment_id: null
        };
        dialogStartISO.value = startISO;
        dialogEndISO.value = endISO;
        dialogOpen.value = true;
    }

    // ── persistMoveOrResize — drag/resize do FC ──
    // Fluxo:
    //   1. Bail se for ocorrência virtual (sem id real)
    //   2. Conflict-check ANTES de confirmar (revert+toast imediato evita
    //      sequência ruim de "confirma? > ah não, conflito")
    //   3. Confirm dialog descrevendo a alteração ("trocando Leonardo de
    //      16h para 15:15h, confirma?")
    //   4. Se aceitar, UPDATE + toast success. Se recusar, revert.
    async function persistMoveOrResize(info, actionLabel) {
        try {
            const ev = info?.event;
            if (!ev) return;
            const id = ev.id;

            if (!id || !isUuid(id)) {
                info?.revert?.();
                toast.add({
                    severity: 'info',
                    summary: 'Sessão recorrente',
                    detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".',
                    life: 4500
                });
                return;
            }

            const startISO = ev.start ? ev.start.toISOString() : null;
            const endISO = ev.end ? ev.end.toISOString() : null;
            if (!startISO || !endISO) throw new Error('Compromisso sem start/end.');

            const start = new Date(startISO);
            const end = new Date(endISO);
            const breakMin = settings.value?.session_break_min || 0;

            const conflict = allRows.value.find((r) => {
                if (!r.inicio_em || r.id === id) return false;
                const rS = new Date(r.inicio_em);
                const rE = new Date(r.fim_em || r.inicio_em);
                return start < new Date(rE.getTime() + breakMin * 60000) && end > rS;
            });

            if (conflict) {
                info?.revert?.();
                toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 });
                return;
            }

            // Confirm: monta mensagem descrevendo o que mudou. Usa HTML
            // (renderizado via v-html no slot #message do ConfirmDialog em
            // MelissaLayout) pra destacar datas/horas em <strong>. Subject
            // é escapado pra evitar XSS via nome de paciente.
            const oldStart = info.oldEvent?.start || null;
            const oldEnd = info.oldEvent?.end || null;
            const ext = ev.extendedProps || {};
            const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento';
            const subjectEsc = _esc(subject);

            const startMoved = oldStart && start.getTime() !== oldStart.getTime();
            const endMoved = oldEnd && end.getTime() !== oldEnd.getTime();

            let message;
            if (startMoved) {
                const sameDay = oldStart.toDateString() === start.toDateString();
                if (sameDay) {
                    // Mudou só hora (mesmo dia)
                    message =
                        `Está trocando o horário de ${subjectEsc} de ` +
                        `<strong>${_fmtH(oldStart)}</strong> para ` +
                        `<strong>${_fmtH(start)}</strong>. Confirma?`;
                } else {
                    // Mudou de dia (com ou sem mudança de hora) — destaca
                    // data E hora em ambos os lados
                    message =
                        `Está movendo ${subjectEsc} de ` +
                        `<strong>${_fmtD(oldStart)} ${_fmtH(oldStart)}</strong> para ` +
                        `<strong>${_fmtD(start)} ${_fmtH(start)}</strong>. Confirma?`;
                }
            } else if (endMoved) {
                // Só resize — mostra duração antiga → nova
                const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000);
                const newDur = Math.round((end.getTime() - start.getTime()) / 60000);
                message =
                    `Está alterando a duração de ${subjectEsc} de ` +
                    `<strong>${oldDur}min</strong> para ` +
                    `<strong>${newDur}min</strong>. Confirma?`;
            } else {
                message = `Confirmar alteração de ${subjectEsc}?`;
            }

            const accepted = await new Promise((resolve) => {
                confirm.require({
                    header: actionLabel,
                    message,
                    icon: 'pi pi-clock',
                    acceptLabel: 'Confirmar',
                    rejectLabel: 'Cancelar',
                    acceptClass: 'p-button-primary',
                    rejectClass: 'p-button-text',
                    accept: () => resolve(true),
                    reject: () => resolve(false),
                    onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar
                });
            });

            if (!accepted) {
                info?.revert?.();
                return;
            }

            await update(id, { inicio_em: startISO, fim_em: endISO });
            toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 });
        } catch (e) {
            info?.revert?.();
            toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 });
        }
    }

    // ── onEditSeriesOccurrence — abre 2º dialog empilhado pra editar
    //    UMA ocorrência específica da série (preservando o dialog principal
    //    aberto por baixo). Substituiu o pattern antigo de mutar
    //    dialogEventRow in-place (que silenciosamente trocava os dados do
    //    dialog atual e confundia o usuário). Adicionado 2026-05-11.
    function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) {
        const current = dialogEventRow.value || {};
        occDialogEventRow.value = {
            ...current,
            id: id || null,
            inicio_em,
            fim_em,
            recurrence_date,
            _is_virtual: is_virtual,
            // is_occurrence=true sinaliza pro AgendaEventDialog que essa row
            // ja é uma ocorrencia de serie — composer.isEdit retorna true e o
            // lifecycle init pula direto pro step 2 (sem mostrar seletor de
            // tipo de compromisso). Sem isso, ocorrencia virtual (id=null)
            // abria step 1 "escolha o tipo" indevidamente. 2026-05-12.
            is_occurrence: true
        };
        occDialogStartISO.value = inicio_em || '';
        occDialogEndISO.value = fim_em || '';
        occDialogOpen.value = true;
    }

    // ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
    //
    // `row` opcional: row completa quando o chamador NÃO abriu o dialog antes
    // (MelissaEventoPanel clica direto no evento → não há dialogEventRow ainda).
    // Sem isso, recurrence_id/patient_id caem pra null e criavam row órfã.
    // ── Status change (Fase 5, 2026-05-14) ─────────────────────────
    // Pra realizado/faltou/cancelado: abre confirm dialog com defaults
    // vindos de financial_exceptions + billing_contracts, deixa terapeuta
    // override caso a caso. Pros outros status (agendado/confirmado/
    // remarcado): aplicação direta (path legacy).
    async function onUpdateSeriesEvent(arg) {
        const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
        try {
            // realizado/faltou/cancelado abrem dialog forward.
            // agendado (reverse transition) abre dialog se houver artefatos
            // pendentes a desfazer: cobrança pendente, multa, saldo consumido.
            const isForward = ['realizado', 'faltou', 'cancelado'].includes(status);
            const isReverse = status === 'agendado';
            if (!isForward && !isReverse) {
                await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
                return;
            }

            const row = callerRow || dialogEventRow.value || {};
            const ctx = await _loadStatusChangeContext({ row, eventoId: id, status });

            // Se nada pra perguntar (ex: status sem regra + sem contrato + sem record pendente), aplica direto
            if (!_needsConfirmDialog(status, ctx)) {
                await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
                return;
            }

            // Abre dialog e espera decisão
            const decision = await _openStatusDialog({
                evento: row,
                novoStatus: status,
                regraExcecao: ctx.regraExcecao,
                billingContract: ctx.billingContract,
                billingContractStyle: ctx.billingContract?.charging_style ?? null,
                pendingRecord: ctx.pendingRecord,
                existingPaidRecord: ctx.existingPaidRecord || null,
                sessionPrice: Number(row.price ?? 0),
                reverseArtifacts: ctx.reverseArtifacts || null
            });
            if (!decision) {
                // User cancelou — reverte status no form do AgendaEventDialog
                // (se o trigger veio de lá). Callback opcional.
                if (typeof onReject === 'function') onReject();
                return;
            }

            // 1) Materializa ocorrência virtual (ou só update status) — usa o id real
            const realId = await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });

            // 2) Aplica decisões do dialog
            await _applyStatusDecisions({
                eventoId: realId,
                row,
                novoStatus: status,
                ctx,
                decision
            });

            // 3) Reload do range pra propagar paymentState/Amount atualizados
            // pro FullCalendar + popover. Sem isso, badge $ e label "A receber"
            // ficam stale até trocar de view ou F5. Caso típico: faltou+multa
            // mostra R$ original (cancelled) em vez do R$ multa novo.
            await _reloadRange();
        } catch (e) {
            toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
        }
    }

    // Aplica apenas o UPDATE de status (materializando se virtual). Path legacy
    // pra status que não precisam de dialog. Retorna o id real do evento
    // (pra Fase 5 saber qual record/saldo manipular depois).
    async function _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }) {
        if (id) {
            await update(id, { status });
            return id;
        }
        if (!is_virtual || !inicio_em) return null;

        const row = callerRow || dialogEventRow.value || {};
        const rid = row.recurrence_id ?? row.serie_id ?? dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
        const rDate = recurrence_date || inicio_em?.slice(0, 10);

        if (!rid) {
            toast.add({ severity: 'warn', summary: 'Erro', detail: 'Não foi possível identificar a regra de recorrência desta ocorrência.', life: 4000 });
            return null;
        }

        const { data: existing, error: exErr } = await tenantDb().from('agenda_eventos')
            .select('id')
            .eq('recurrence_id', rid)
            .eq('recurrence_date', rDate)
            .maybeSingle();
        if (exErr) throw exErr;

        if (existing?.id) {
            await update(existing.id, { status });
            return existing.id;
        } else {
            const created = await create({
                owner_id: ownerId.value,
                recurrence_id: rid,
                recurrence_date: rDate,
                tipo: 'sessao',
                status,
                inicio_em,
                fim_em,
                visibility_scope: 'public',
                titulo: row.titulo || 'Sessão',
                patient_id: row.patient_id || row.paciente_id || null,
                determined_commitment_id: row.determined_commitment_id || null,
                modalidade: row.modalidade || 'presencial',
                price: row.price ?? null
            });
            return created?.id ?? null;
        }
    }

    // Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
    // Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId
    // do escopo do composable. Lógica pura mora em agendaBilling.service.
    async function _loadStatusChangeContext({ row, eventoId, status }) {
        return loadStatusChangeContext({
            supabase,
            row,
            eventoId,
            status,
            ownerId: ownerId.value,
            tenantId: clinicTenantId.value
        });
    }

    // _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
    // do agendaBilling.service. Pure — sem deps de composable state.
    const _needsConfirmDialog = needsStatusConfirmDialog;

    // Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
    // _applyStatusDecisions agora é wrapper fino sobre applyStatusDecisions do
    // service (Fase B2). Injeta supabase + toast + ownerId + tenantId do escopo.
    async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
        return applyStatusDecisions({
            supabase,
            toast,
            eventoId,
            row,
            novoStatus,
            ctx,
            decision,
            ownerId: ownerId.value,
            tenantId: clinicTenantId.value
        });
    }


    // ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
    const onDialogSave = _buildOnDialogSave(deps);
    const onDialogDelete = _buildOnDialogDelete(deps);

    return {
        onEditEvento,
        onCreateEvento,
        onCreateEventoForPatient,
        onSelectTime,
        persistMoveOrResize,
        onEditSeriesOccurrence,
        onUpdateSeriesEvent,
        onDialogSave,
        onDialogDelete
    };
}

// ───────────────────────────────────────────────────────────────────────────
// onDialogSave — porte direto de AgendaTerapeutaPage.onDialogSave (linhas
// 1714-2116). Cobre 7 cases: criação avulsa, sessão única (A/B), série nova
// (C), edição "somente esta" (D), "este e seguintes" (E), "todos" (F),
// "todos sem exceção" (G), além de tratamento explícito de exclusion
// constraint (conflict de horário).
// ───────────────────────────────────────────────────────────────────────────
function _buildOnDialogSave(deps) {
    const {
        toast,
        ownerId, clinicTenantId, settings,
        eventsError,
        create, update,
        createRule, updateRule, splitRuleAt, upsertException,
        saveRuleItems, propagateToSerie,
        dialogOpen, dialogEventRow,
        occDialogOpen, serieRefreshTick,
        _reloadRange
    } = deps;

    return async function onDialogSave(arg) {
        let normalized = null;

        // _occurrenceMode: payload veio do 2º dialog empilhado (editar UMA
        // ocorrencia da serie). Nesse caso, fechamos o dialog da OCORRENCIA
        // (nao o pai) e bumpamos serieRefreshTick pra que o pai re-rode
        // loadSerieEvents e a pill reflita o novo status. Sem isso, o pai
        // fechava silenciosamente e a pill ficava com status stale. 2026-05-12.
        const isOccurrence = !!arg?._occurrenceMode;
        function closeDialog() {
            if (isOccurrence) {
                occDialogOpen.value = false;
                serieRefreshTick.value++;
            } else {
                dialogOpen.value = false;
            }
        }

        try {
            const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload');
            const payload = isWrapped ? arg.payload : arg;
            const recorrencia = arg?.recorrencia ?? null;
            const editMode = arg?.editMode ?? null;
            const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null;
            const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null;
            const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null);

            normalized = { ...(payload || {}) };

            if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value;

            const clinicId = clinicTenantId.value;
            if (!clinicId) throw new Error('tenant da clínica não encontrado no tenantStore.');

            if (!normalized.visibility_scope) normalized.visibility_scope = 'public';

            normalized.tipo = normalizeEventoTipo(normalized.tipo || deriveEventoTipoForNewEvent(normalized), EVENTO_TIPO.SESSAO);

            if (!normalized.status) normalized.status = 'agendado';
            if (!String(normalized.titulo || '').trim()) normalized.titulo = deriveTituloDefaultByTipo(normalized.tipo);

            if (!normalized.paciente_id || !isUuid(normalized.paciente_id)) normalized.paciente_id = null;

            if (normalized.tipo === EVENTO_TIPO.BLOQUEIO) {
                normalized.paciente_id = null;
                normalized.determined_commitment_id = null;
                if (!normalized.visibility_scope) normalized.visibility_scope = 'busy_only';
            }

            if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) {
                normalized.determined_commitment_id = null;
            }

            // ── CASO C / C2: criação RECORRENTE ───────────────────────────────
            if (recorrencia?.tipo === 'recorrente' && !recurrenceId) {
                const startDate = new Date(normalized.inicio_em);
                const tipoFreq = recorrencia.tipoFreq ?? 'semanal';
                const dow = recorrencia.diaSemana ?? startDate.getDay();
                const firstRecISO = startDate.toISOString().slice(0, 10);

                let ruleType = 'weekly';
                let interval = 1;
                let weekdays = [dow];
                if (tipoFreq === 'quinzenal') {
                    ruleType = 'weekly';
                    interval = 2;
                } else if (tipoFreq === 'diasEspecificos') {
                    ruleType = 'custom_weekdays';
                    weekdays = recorrencia.diasSemana?.length ? recorrencia.diasSemana : [dow];
                }

                const rule = {
                    owner_id: normalized.owner_id,
                    therapist_id: normalized.terapeuta_id ?? null,
                    patient_id: normalized.paciente_id ?? null,
                    determined_commitment_id: normalized.determined_commitment_id ?? null,
                    type: ruleType,
                    interval,
                    weekdays,
                    start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8),
                    end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50),
                    duration_min: recorrencia.duracaoMin ?? 50,
                    timezone: settings.value?.timezone || 'America/Sao_Paulo',
                    start_date: firstRecISO,
                    end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null,
                    max_occurrences: recorrencia.qtdSessoes ?? null,
                    open_ended: !recorrencia.dataFim && !recorrencia.qtdSessoes,
                    modalidade: normalized.modalidade ?? 'presencial',
                    titulo_custom: normalized.titulo_custom ?? null,
                    observacoes: normalized.observacoes ?? null,
                    extra_fields: normalized.extra_fields ?? null,
                    price: normalized.price ?? null,
                    insurance_plan_id: normalized.insurance_plan_id ?? null,
                    insurance_guide_number: normalized.insurance_guide_number ?? null,
                    insurance_value: normalized.insurance_value ?? null,
                    insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
                    status: 'ativo'
                };
                const createdRule = await createRule(rule);

                if (id && createdRule?.id) {
                    await update(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO });
                }

                if (recorrencia?.conflitos?.length && createdRule?.id) {
                    const exceptions = recorrencia.conflitos.map((c) => ({
                        recurrence_id: createdRule.id,
                        original_date: c.date,
                        type:
                            c.conflict.type === 'feriado' ? 'holiday_block'
                                : c.conflict.type === 'bloqueado' ? 'cancel_session'
                                : c.conflict.type === 'folga' ? 'cancel_session'
                                : 'cancel_session',
                        reason: c.conflict.label
                    }));
                    const { error: exErr } = await tenantDb().from('recurrence_exceptions').insert(exceptions);
                    if (exErr) console.warn('[useMelissaAgenda] exceptions insert', exErr);
                }

                if (createdRule?.id && recorrencia.commitmentItems?.length) {
                    await saveRuleItems(createdRule.id, recorrencia.commitmentItems);
                }

                // Opção C1 (2026-05-13): cobrança decidida ANTES de salvar via
                // chargeMode no payload. Substituiu o confirm.require pós-save.
                //   'package'     → 1 billing_contract com valor total da série
                //   'per_session' → materializa N agenda_eventos + N financial_records
                //   'none'        → nada
                const recChargeMode = arg?.chargeMode || 'none';
                let chargeInfo = null;
                if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
                    if (recChargeMode === 'package') {
                        chargeInfo = await _createPackageContractService({
                            supabase,
                            rule: createdRule,
                            normalized,
                            recorrencia,
                            tenantId: clinicId,
                            packageStyle: arg?.packageStyle || 'upfront',
                            paymentMethod: arg?.paymentMethod || 'link',
                            markPaidNow: arg?.markPaidNow === true
                        });
                    } else if (recChargeMode === 'per_session') {
                        chargeInfo = await _materializeAndChargePerSessionService({ supabase, rule: createdRule, normalized, recorrencia, tenantId: clinicId });
                    }
                }

                // chargeMode='none' (C6 doc): materializa a 1ª ocorrência
                // sem criar financial_record. As demais ficam virtuais.
                // Honra: "1ª materializada com badge $ a cobrar; outras 3
                // virtuais expandidas em runtime, limpas até interação".
                // package/per_session ja materializam dentro dos proprios
                // helpers; package 'saldo' intencionalmente NÃO materializa
                // (doc C8 — modelo Cliniko, todas virtuais ate interacao).
                // IMPORTANTE: agendaRepository.createAgendaEvento dropa
                // 'paciente_id' (campo legado). USAR 'patient_id' (English),
                // que é o nome real da coluna em agenda_eventos.
                const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
                if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
                    try {
                        const durMin = createdRule.duration_min || 50;
                        const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
                        const firstISO = createdRule.start_date;
                        const startDt = new Date(`${firstISO}T00:00:00`);
                        startDt.setHours(hh, mm, 0, 0);
                        const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
                        await create({
                            owner_id: createdRule.owner_id,
                            terapeuta_id: createdRule.therapist_id ?? null,
                            recurrence_id: createdRule.id,
                            recurrence_date: firstISO,
                            tipo: normalized.tipo || 'sessao',
                            status: normalized.status || 'agendado',
                            titulo: normalized.titulo || 'Sessão',
                            inicio_em: startDt.toISOString(),
                            fim_em: endDt.toISOString(),
                            patient_id: _patientIdForFirst,
                            determined_commitment_id: normalized.determined_commitment_id ?? null,
                            modalidade: normalized.modalidade ?? 'presencial',
                            observacoes: normalized.observacoes ?? null,
                            extra_fields: normalized.extra_fields ?? null,
                            price: normalized.price ?? null,
                            insurance_plan_id: normalized.insurance_plan_id ?? null,
                            insurance_guide_number: normalized.insurance_guide_number ?? null,
                            insurance_value: normalized.insurance_value ?? null,
                            insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
                            visibility_scope: normalized.visibility_scope || 'public'
                        });
                    } catch (e) {
                        console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
                    }
                }

                const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
                toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
                if (chargeInfo?.toast) toast.add(chargeInfo.toast);
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── CASO D: edição "somente_este" ─────────────────────────────────
            if (recurrenceId && editMode === 'somente_este') {
                let eventId = id ?? null;

                if (id) {
                    await update(id, pickDbFields(normalized));
                    if (originalDate) {
                        await upsertException({
                            recurrence_id: recurrenceId,
                            original_date: originalDate,
                            type: 'reschedule_session',
                            new_date: normalized.inicio_em?.slice(0, 10),
                            new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null,
                            new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null,
                            modalidade: normalized.modalidade ?? null,
                            titulo_custom: normalized.titulo_custom ?? null,
                            observacoes: normalized.observacoes ?? null,
                            extra_fields: normalized.extra_fields ?? null
                        });
                    }
                } else if (originalDate) {
                    await upsertException({
                        recurrence_id: recurrenceId,
                        original_date: originalDate,
                        type: 'reschedule_session',
                        new_date: normalized.inicio_em?.slice(0, 10),
                        new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null,
                        new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null,
                        modalidade: normalized.modalidade ?? null,
                        titulo_custom: normalized.titulo_custom ?? null,
                        observacoes: normalized.observacoes ?? null,
                        extra_fields: normalized.extra_fields ?? null
                    });
                    if (arg.onSaved) {
                        const { data: existing } = await tenantDb().from('agenda_eventos')
                            .select('id')
                            .eq('recurrence_id', recurrenceId)
                            .eq('recurrence_date', originalDate)
                            .maybeSingle();
                        if (existing?.id) {
                            eventId = existing.id;
                        } else {
                            const mat = await create({
                                owner_id: normalized.owner_id,
                                recurrence_id: recurrenceId,
                                recurrence_date: originalDate,
                                tipo: normalized.tipo,
                                status: normalized.status,
                                inicio_em: normalized.inicio_em,
                                fim_em: normalized.fim_em,
                                titulo: normalized.titulo,
                                patient_id: normalized.patient_id,
                                determined_commitment_id: normalized.determined_commitment_id,
                                modalidade: normalized.modalidade ?? 'presencial',
                                observacoes: normalized.observacoes ?? null,
                                extra_fields: normalized.extra_fields ?? null,
                                price: normalized.price ?? null,
                                insurance_plan_id: normalized.insurance_plan_id ?? null,
                                insurance_guide_number: normalized.insurance_guide_number ?? null,
                                insurance_value: normalized.insurance_value ?? null,
                                insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
                            });
                            eventId = mat.id;
                        }
                    }
                }

                if (eventId) await arg.onSaved?.(eventId, { markCustomized: true });

                toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── CASO E: edição "este_e_seguintes" ─────────────────────────────
            if (recurrenceId && editMode === 'este_e_seguintes' && originalDate) {
                const newRuleId = await splitRuleAt(recurrenceId, originalDate);
                const startDate = new Date(normalized.inicio_em);
                await updateRule(newRuleId, {
                    weekdays: [startDate.getDay()],
                    start_time: startDate.toTimeString().slice(0, 8),
                    end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
                    duration_min: recorrencia?.duracaoMin ?? 50,
                    modalidade: normalized.modalidade ?? 'presencial',
                    titulo_custom: normalized.titulo_custom ?? null,
                    observacoes: normalized.observacoes ?? null,
                    extra_fields: normalized.extra_fields ?? null,
                    price: normalized.price ?? null,
                    insurance_plan_id: normalized.insurance_plan_id ?? null,
                    insurance_guide_number: normalized.insurance_guide_number ?? null,
                    insurance_value: normalized.insurance_value ?? null,
                    insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
                });

                const serviceItemsE = arg.serviceItems;
                if (newRuleId && serviceItemsE?.length) {
                    await saveRuleItems(newRuleId, serviceItemsE);
                    await propagateToSerie(newRuleId, serviceItemsE, { fromDate: originalDate });
                }
                if (id) await arg.onSaved?.(id);

                toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── CASO F: edição "todos" ────────────────────────────────────────
            if (recurrenceId && editMode === 'todos') {
                const startDate = new Date(normalized.inicio_em);
                await updateRule(recurrenceId, {
                    weekdays: [startDate.getDay()],
                    start_time: startDate.toTimeString().slice(0, 8),
                    end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
                    duration_min: recorrencia?.duracaoMin ?? 50,
                    modalidade: normalized.modalidade ?? 'presencial',
                    titulo_custom: normalized.titulo_custom ?? null,
                    observacoes: normalized.observacoes ?? null,
                    extra_fields: normalized.extra_fields ?? null,
                    price: normalized.price ?? null,
                    insurance_plan_id: normalized.insurance_plan_id ?? null,
                    insurance_guide_number: normalized.insurance_guide_number ?? null,
                    insurance_value: normalized.insurance_value ?? null,
                    insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
                });

                await tenantDb().from('agenda_eventos')
                    .update({
                        modalidade: normalized.modalidade ?? 'presencial',
                        titulo_custom: normalized.titulo_custom ?? null,
                        observacoes: normalized.observacoes ?? null,
                        extra_fields: normalized.extra_fields ?? null,
                        price: normalized.price ?? null,
                        insurance_plan_id: normalized.insurance_plan_id ?? null,
                        insurance_guide_number: normalized.insurance_guide_number ?? null,
                        insurance_value: normalized.insurance_value ?? null,
                        insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
                    })
                    .eq('recurrence_id', recurrenceId);

                const serviceItemsF = arg.serviceItems;
                if (recurrenceId && serviceItemsF?.length) {
                    await saveRuleItems(recurrenceId, serviceItemsF);
                    await propagateToSerie(recurrenceId, serviceItemsF);
                }
                if (id) await arg.onSaved?.(id);

                toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── CASO G: edição "todos_sem_excecao" ────────────────────────────
            if (recurrenceId && editMode === 'todos_sem_excecao') {
                const startDate = new Date(normalized.inicio_em);
                await updateRule(recurrenceId, {
                    weekdays: [startDate.getDay()],
                    start_time: startDate.toTimeString().slice(0, 8),
                    end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
                    duration_min: recorrencia?.duracaoMin ?? 50,
                    modalidade: normalized.modalidade ?? 'presencial',
                    titulo_custom: normalized.titulo_custom ?? null,
                    observacoes: normalized.observacoes ?? null,
                    extra_fields: normalized.extra_fields ?? null,
                    price: normalized.price ?? null,
                    insurance_plan_id: normalized.insurance_plan_id ?? null,
                    insurance_guide_number: normalized.insurance_guide_number ?? null,
                    insurance_value: normalized.insurance_value ?? null,
                    insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
                });

                await tenantDb().from('agenda_eventos')
                    .update({
                        modalidade: normalized.modalidade ?? 'presencial',
                        titulo_custom: normalized.titulo_custom ?? null,
                        observacoes: normalized.observacoes ?? null,
                        extra_fields: normalized.extra_fields ?? null,
                        price: normalized.price ?? null,
                        insurance_plan_id: normalized.insurance_plan_id ?? null,
                        insurance_guide_number: normalized.insurance_guide_number ?? null,
                        insurance_value: normalized.insurance_value ?? null,
                        insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
                        services_customized: false
                    })
                    .eq('recurrence_id', recurrenceId);

                const serviceItemsG = arg.serviceItems;
                if (recurrenceId && serviceItemsG?.length) {
                    await saveRuleItems(recurrenceId, serviceItemsG);
                    await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true });
                }
                if (id) await arg.onSaved?.(id);

                toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas (sem exceção).', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── CASO A/B: evento avulso ou sessão única ──────────────────────
            const dbPayload = pickDbFields(normalized);

            let createdEventId = null;
            if (id) {
                await update(id, dbPayload);
                await arg.onSaved?.(id);
                createdEventId = id;
                toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 });
            } else {
                const created = await create(dbPayload);
                await arg.onSaved?.(created.id);
                createdEventId = created.id;
                toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
            }

            // Opção C1 (2026-05-13): cobrança avulsa via chargeMode='session'.
            // Cria 1 financial_record vinculado ao evento. Falha aqui não
            // reverte a sessão — toast warn pra gerar manualmente depois.
            // Refatorado 2026-05-16: paymentSettlement substituído por
            // paymentMethod + markPaidNow. Handler aplica payment_method sempre;
            // status='paid' apenas quando markPaidNow && method !== 'link'.
            if (arg?.chargeMode === 'session' && createdEventId) {
                try {
                    // Convênio: valor vem em insurance_value (não price);
                    // payment_method fixo em 'convenio' (não asaas/pix/etc) —
                    // o plano paga a clínica no fechamento mensal, nunca via
                    // gateway ou no caixa. markPaidNow ignorado pra convênio
                    // (record sempre nasce pending até a baixa via Financeiro).
                    const isConvenio = !!normalized.insurance_plan_id;
                    const amount = isConvenio
                        ? (normalized.insurance_value ?? 0)
                        : (normalized.price ?? 0);
                    const dueDate = normalized.inicio_em
                        ? new Date(normalized.inicio_em).toISOString().slice(0, 10)
                        : new Date().toISOString().slice(0, 10);
                    const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
                        p_tenant_id: clinicId,
                        p_owner_id: normalized.owner_id,
                        p_patient_id: normalized.paciente_id ?? null,
                        p_agenda_evento_id: createdEventId,
                        p_amount: amount,
                        p_due_date: dueDate
                    });
                    if (cobErr) throw cobErr;

                    // Pós-RPC: ajusta payment_method (sempre) e status (só se
                    // markPaidNow=true e método direto). Convênio força method
                    // = 'convenio' e ignora markPaidNow.
                    //   convenio                                  → payment_method='convenio', status=pending
                    //   method='link'                             → payment_method='asaas', status=pending
                    //   method=pix/etc + markPaidNow=false        → payment_method=<>, status=pending
                    //   method=pix/etc + markPaidNow=true         → payment_method=<>, status='paid', paid_at=now()
                    const method = isConvenio
                        ? 'convenio'
                        : (arg?.paymentMethod || 'link');
                    const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
                    try {
                        const { data: recRow } = await tenantDb().from('financial_records')
                            .select('id')
                            .eq('agenda_evento_id', createdEventId)
                            .order('created_at', { ascending: false })
                            .limit(1)
                            .single();
                        if (recRow?.id) {
                            const patch = {
                                updated_at: new Date().toISOString(),
                                payment_method: method === 'link' ? 'asaas' : method
                            };
                            if (paidNow) {
                                patch.status = 'paid';
                                patch.paid_at = new Date().toISOString();
                            }
                            const { error: upErr } = await tenantDb().from('financial_records').update(patch).eq('id', recRow.id);
                            if (upErr) throw upErr;
                        }
                    } catch (e2) {
                        console.warn('[useMelissaAgenda] pos-cobranca update falhou:', e2?.message);
                    }

                    const methodLabel = {
                        pix: 'PIX',
                        dinheiro: 'dinheiro',
                        deposito: 'depósito',
                        cartao_maquininha: 'cartão (maquininha)',
                        convenio: 'convênio'
                    }[method] || null;
                    toast.add({
                        severity: 'success',
                        summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
                        detail: paidNow
                            ? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
                            : `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
                        life: 3500
                    });
                } catch (e) {
                    toast.add({
                        severity: 'warn',
                        summary: 'Sessão salva, mas cobrança falhou',
                        detail: e?.message || 'Você pode gerar manualmente pelo Financeiro.',
                        life: 5000
                    });
                }
            }

            closeDialog();
            await _reloadRange();
        } catch (e) {
            const msg = String(e?.message || '');

            if (msg.includes('recurrence_rules_dates_chk') || (msg.includes('violates check constraint') && msg.includes('recurrence_rules'))) {
                toast.add({
                    severity: 'warn',
                    summary: 'Não foi possível dividir a série',
                    detail: 'Esta é a primeira sessão da série. Para alterar todas as ocorrências, selecione "Todos" ou "Todos sem exceção".',
                    life: 6000
                });
                return;
            }

            const isOverlap =
                e?.code === '23P01' ||
                msg.includes('agenda_eventos_sem_sobreposicao') ||
                msg.includes('exclusion constraint') ||
                msg.includes('conflicting key value violates exclusion constraint');

            if (isOverlap) {
                let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.';
                try {
                    if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) {
                        const { data: conflicting } = await tenantDb().from('agenda_eventos')
                            .select('titulo, inicio_em, fim_em')
                            .eq('owner_id', normalized.owner_id)
                            .lt('inicio_em', normalized.fim_em)
                            .gt('fim_em', normalized.inicio_em)
                            .limit(1)
                            .maybeSingle();

                        if (conflicting) {
                            const ini = new Date(conflicting.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
                            const fim = new Date(conflicting.fim_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
                            const titulo = conflicting.titulo || 'Compromisso';
                            detail = `Conflito com "${titulo}" (${ini} → ${fim}). Ajuste o horário ou a duração.`;
                        }
                    }
                } catch {
                    /* mantém detail genérico */
                }

                toast.add({ severity: 'warn', summary: 'Conflito de horário', detail, life: 7000 });
                return;
            }

            toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar.', life: 4500 });
        }
    };
}

// ───────────────────────────────────────────────────────────────────────────
// onDialogDelete — porte direto de AgendaTerapeutaPage:2118-2204
// ───────────────────────────────────────────────────────────────────────────
function _buildOnDialogDelete(deps) {
    const {
        toast,
        ownerId, clinicTenantId,
        create, update, remove,
        cancelRule, cancelRuleFrom, upsertException,
        dialogOpen, dialogEventRow,
        occDialogOpen, serieRefreshTick,
        eventsError,
        _reloadRange
    } = deps;

    return async function onDialogDelete(arg) {
        const id = typeof arg === 'string' ? arg : arg?.id;
        const editMode = typeof arg === 'string' ? null : arg?.editMode;
        const recurrenceId = typeof arg === 'string' ? null : (arg?.recurrence_id ?? arg?.serie_id ?? null);
        const originalDate = typeof arg === 'string' ? null : (arg?.original_date ?? dialogEventRow.value?.original_date ?? null);

        // Mesma logica do save: delete acionado do 2º dialog empilhado fecha
        // a ocorrencia + bumpa pill refresh; do pai fecha o pai. 2026-05-12.
        const isOccurrence = typeof arg === 'object' && !!arg?._occurrenceMode;
        function closeDialog() {
            if (isOccurrence) {
                occDialogOpen.value = false;
                serieRefreshTick.value++;
            } else {
                dialogOpen.value = false;
            }
        }

        try {
            // ── Somente este evento / ocorrência ──
            if (!recurrenceId || editMode === 'somente_este') {
                if (originalDate && recurrenceId) {
                    await upsertException({
                        recurrence_id: recurrenceId,
                        original_date: originalDate,
                        type: 'cancel_session'
                    });
                } else if (id) {
                    await remove(id);
                }
                toast.add({ severity: 'success', summary: 'Excluído', detail: 'Sessão removida.', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── Este e os seguintes ──
            if (editMode === 'este_e_seguintes' && originalDate) {
                await cancelRuleFrom(recurrenceId, originalDate);
                toast.add({ severity: 'success', summary: 'Excluído', detail: 'Esta sessão e as seguintes foram canceladas.', life: 2500 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // ── Todos (encerrar série, manter sessão atual como avulsa) ──
            if (editMode === 'todos') {
                const row = dialogEventRow.value || {};
                const isVirtual = row.is_occurrence && !id;

                if (isVirtual) {
                    const rDate = row.original_date || row.inicio_em?.slice(0, 10);
                    const existing = await tenantDb().from('agenda_eventos')
                        .select('id')
                        .eq('recurrence_id', recurrenceId)
                        .eq('recurrence_date', rDate)
                        .maybeSingle();

                    if (existing.data?.id) {
                        await update(existing.data.id, { recurrence_id: null, recurrence_date: null });
                    } else {
                        await create({
                            owner_id: ownerId.value,
                            tipo: row.tipo || 'sessao',
                            status: row.status || 'agendado',
                            inicio_em: row.inicio_em,
                            fim_em: row.fim_em,
                            titulo: row.titulo || 'Sessão',
                            patient_id: row.patient_id || row.paciente_id || null,
                            determined_commitment_id: row.determined_commitment_id || null,
                            modalidade: row.modalidade || 'presencial',
                            price: row.price ?? null,
                            observacoes: row.observacoes || null,
                            visibility_scope: 'public'
                        });
                    }
                } else if (id) {
                    await update(id, { recurrence_id: null, recurrence_date: null });
                }

                await cancelRule(recurrenceId);
                toast.add({ severity: 'success', summary: 'Série encerrada', detail: 'A série foi encerrada. Esta sessão foi mantida como avulsa.', life: 3000 });
                closeDialog();
                await _reloadRange();
                return;
            }

            // fallback
            if (id) await remove(id);
            toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
            closeDialog();
            await _reloadRange();
        } catch (e) {
            toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 });
        }
    };
}


// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
