/*
 * 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 { 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';

// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);

// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
// `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
// `max_session_duration_min` for adicionado.
const MAX_SESSION_MINUTES = 120;

function isUuid(v) {
    return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}

function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
    const s = String(t || '').trim().toLowerCase();
    if (!s) return fallback;
    if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
    if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
    return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
}

function deriveEventoTipoForNewEvent(payload) {
    const vis = String(payload?.visibility_scope || '').toLowerCase();
    const title = String(payload?.titulo || '').toLowerCase();
    if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
    return EVENTO_TIPO.SESSAO;
}

function deriveTituloDefaultByTipo(tipo) {
    return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
}

function pickDbFields(obj) {
    const allowed = [
        'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
        'tipo', 'status', 'titulo', 'observacoes',
        'inicio_em', 'fim_em', 'visibility_scope',
        'mirror_of_event_id', 'mirror_source',
        'determined_commitment_id', 'titulo_custom', 'extra_fields',
        'recurrence_id', 'recurrence_date',
        'price', 'insurance_plan_id', 'insurance_guide_number',
        'insurance_value', 'insurance_plan_service_id'
    ];
    const out = {};
    for (const k of allowed) {
        if (obj[k] !== undefined) out[k] = obj[k];
    }
    return out;
}

function _addMinutesToTime(timeStr, minutes) {
    const [h, m] = String(timeStr || '09:00').split(':').map(Number);
    const total = h * 60 + m + Number(minutes || 0);
    const hh = Math.floor(total / 60);
    const mm = total % 60;
    return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
}

// ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ─────
function pickColor(tipo, status, isOccurrence) {
    const s = String(status || '').toLowerCase();
    if (s === 'realizado' || s === 'realizada') return '#10b981';
    if (s === 'faltou') return '#ef4444';
    if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';

    const t = String(tipo || '').toLowerCase();
    if (t === 'bloqueio') return '#64748b';
    if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
    if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
    return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo
}

function isoToDecimalHour(iso) {
    if (!iso) return 0;
    const d = new Date(iso);
    return d.getHours() + d.getMinutes() / 60;
}

function normalizeForMelissa(r) {
    // 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);

    return {
        id: r.id,
        tipo,
        status: r.status || (isOccurrence ? 'agendado' : ''),
        titulo: r.titulo || r.titulo_custom || '',
        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,
        _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,
                tenant_id: i.tenant_id ?? null,
                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();

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

    // ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
    const eventos = computed(() => allRows.value.map(normalizeForMelissa));

    // ── 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('');

    // 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);
    }

    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,
        _reloadRange
    }));

    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,

        // Bloqueio dialog
        bloqueioDialogOpen,
        bloqueioMode,
        openBloqueioDialog,

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

        // 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
    } = 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 = '';
        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();
        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();

        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 — preview ao alternar editScope no dialog ──
    function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) {
        const current = dialogEventRow.value || {};
        dialogEventRow.value = {
            ...current,
            id: id || null,
            inicio_em,
            fim_em,
            recurrence_date,
            _is_virtual: is_virtual
        };
    }

    // ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
    async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
        try {
            if (id) {
                await update(id, { status });
                return;
            }
            if (!is_virtual || !inicio_em) return;

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

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

            if (existing?.id) {
                await update(existing.id, { status });
            } else {
                const row = dialogEventRow.value || {};
                await create({
                    owner_id: ownerId.value,
                    tenant_id: clinicTenantId.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,
                    price: row.price ?? null
                });
            }
        } catch (e) {
            toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
        }
    }

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

    return {
        onEditEvento,
        onCreateEvento,
        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, confirm,
        ownerId, clinicTenantId, settings,
        eventsError,
        create, update,
        createRule, updateRule, splitRuleAt, upsertException,
        saveRuleItems, propagateToSerie,
        dialogOpen, dialogEventRow,
        _reloadRange
    } = deps;

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

        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_id da clínica não encontrado no tenantStore.');
            normalized.tenant_id = clinicId;

            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 = {
                    tenant_id: clinicId,
                    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,
                        tenant_id: clinicId,
                        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 supabase.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);
                }

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

                if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
                    await _offerBillingContract({ normalized, recorrencia, tenantId: clinicId, confirm, toast });
                }
                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,
                            tenant_id: clinicId,
                            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,
                        tenant_id: clinicId,
                        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 supabase
                            .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,
                                tenant_id: clinicId,
                                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 });
                dialogOpen.value = false;
                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 });
                dialogOpen.value = false;
                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 supabase
                    .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 });
                dialogOpen.value = false;
                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 supabase
                    .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 });
                dialogOpen.value = false;
                await _reloadRange();
                return;
            }

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

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

            dialogOpen.value = false;
            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 supabase
                            .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,
        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);

        try {
            // ── Somente este evento / ocorrência ──
            if (!recurrenceId || editMode === 'somente_este') {
                if (originalDate && recurrenceId) {
                    await upsertException({
                        recurrence_id: recurrenceId,
                        tenant_id: clinicTenantId.value,
                        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 });
                dialogOpen.value = false;
                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 });
                dialogOpen.value = false;
                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 supabase
                        .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,
                            tenant_id: clinicTenantId.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 });
                dialogOpen.value = false;
                await _reloadRange();
                return;
            }

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

// ───────────────────────────────────────────────────────────────────────────
// Helper: oferece geração de billing_contract após criar série recorrente
// com serviços. Chamado APÓS fechar o dialog principal.
// ───────────────────────────────────────────────────────────────────────────
async function _offerBillingContract({ normalized, recorrencia, tenantId, confirm, toast }) {
    const n = recorrencia.qtdSessoes;
    const items = recorrencia.commitmentItems || [];
    const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
    const pacoteFechado = recorrencia.serieValorMode === 'dividir';
    const packagePrice = pacoteFechado ? totalPorSessao : totalPorSessao * n;
    const perSessao = pacoteFechado ? totalPorSessao / n : totalPorSessao;
    const fmtB = (v) => Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });

    return new Promise((resolve) => {
        confirm.require({
            header: 'Gerar contrato de cobrança?',
            message: `${n} sessões — ${fmtB(perSessao)} por sessão. Total da série: ${fmtB(packagePrice)}.`,
            icon: 'pi pi-file',
            acceptLabel: 'Sim, gerar contrato',
            rejectLabel: 'Agora não',
            accept: async () => {
                try {
                    const { error } = await supabase.from('billing_contracts').insert({
                        owner_id: normalized.owner_id,
                        tenant_id: tenantId,
                        patient_id: normalized.paciente_id,
                        type: 'package',
                        total_sessions: n,
                        sessions_used: 0,
                        package_price: packagePrice,
                        status: 'active'
                    });
                    if (error) throw error;
                    toast.add({ severity: 'success', summary: 'Contrato gerado', detail: `Pacote de ${n} sessões: ${fmtB(packagePrice)}.`, life: 3000 });
                } catch (e) {
                    toast.add({ severity: 'warn', summary: 'Erro ao gerar contrato', detail: e?.message, life: 4000 });
                }
                resolve();
            },
            reject: () => resolve()
        });
    });
}
