/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useRecurrence.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/**
 * useRecurrence.js
 * src/features/agenda/composables/useRecurrence.js
 *
 * Coração da nova arquitetura de recorrência.
 * Gera ocorrências dinamicamente no frontend a partir das regras.
 * Nunca grava eventos futuros no banco — apenas regras + exceções.
 *
 * Fluxo:
 *   1. loadRules(ownerId, rangeStart, rangeEnd)     → busca regras ativas
 *   2. loadExceptions(ruleIds, rangeStart, rangeEnd) → busca exceções no range
 *   3. expandRules(rules, exceptions, rangeStart, rangeEnd) → gera ocorrências
 *   4. mergeWithStoredSessions(occurrences, storedRows) → sessões reais sobrepõem
 */

import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from '@/features/agenda/services/_tenantGuards';
import { logRecurrence, logError, logPerf } from '@/support/supportLogger';

// ─── helpers de data ────────────────────────────────────────────────────────

/** 'YYYY-MM-DD' → Date (local, sem UTC shift) */
function parseDate(iso) {
    const [y, m, d] = String(iso).slice(0, 10).split('-').map(Number);
    return new Date(y, m - 1, d);
}

/** Date → 'YYYY-MM-DD' */
function toISO(d) {
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

/** 'HH:MM' ou 'HH:MM:SS' → { hours, minutes } */
function parseTime(t) {
    const [h, m] = String(t || '00:00')
        .split(':')
        .map(Number);
    return { hours: h || 0, minutes: m || 0 };
}

/** Aplica HH:MM a um Date, retorna novo Date */
function applyTime(date, timeStr) {
    const d = new Date(date);
    const { hours, minutes } = parseTime(timeStr);
    d.setHours(hours, minutes, 0, 0);
    return d;
}

/** Avança cursor para o próximo dia-da-semana especificado */
function nextWeekday(fromDate, targetDow) {
    const d = new Date(fromDate);
    const diff = (targetDow - d.getDay() + 7) % 7;
    d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
    return d;
}

// ─── geradores de datas por tipo ─────────────────────────────────────────────

/**
 * Gera array de datas (Date) para uma regra no intervalo [rangeStart, rangeEnd]
 * Respeita: start_date, end_date, max_occurrences, open_ended
 */
export function generateDates(rule, rangeStart, rangeEnd) {
    const ruleStart = parseDate(rule.start_date);
    const ruleEnd = rule.end_date ? parseDate(rule.end_date) : null;
    const effStart = ruleStart > rangeStart ? ruleStart : rangeStart;
    const effEnd = ruleEnd && ruleEnd < rangeEnd ? ruleEnd : rangeEnd;
    const interval = Number(rule.interval || 1);
    const weekdays = (rule.weekdays || []).map(Number);

    if (!weekdays.length) return [];

    const dates = [];

    if (rule.type === 'weekly' || rule.type === 'biweekly') {
        const dow = weekdays[0];
        // primeira ocorrência da série (a partir do start_date da regra)
        const firstInSerie = nextWeekday(ruleStart, dow);

        // conta quantas ocorrências já existem ANTES do range atual
        // para saber o occurrenceCount global correto
        let globalCount = 0;
        const counter = new Date(firstInSerie);
        while (counter < effStart) {
            globalCount++;
            counter.setDate(counter.getDate() + 7 * interval);
        }

        // agora itera a partir do effStart gerando as do range
        const cur = new Date(counter); // está na primeira data >= effStart
        while (cur <= effEnd) {
            if (rule.max_occurrences && globalCount >= rule.max_occurrences) break;
            dates.push(new Date(cur));
            globalCount++;
            cur.setDate(cur.getDate() + 7 * interval);
        }
    } else if (rule.type === 'custom_weekdays') {
        // múltiplos dias da semana, intervalo semanal
        // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
        let occurrenceCount = 0;
        const sortedDows = [...weekdays].sort();

        // Início da semana de ruleStart
        const preStart = new Date(ruleStart);
        preStart.setDate(preStart.getDate() - preStart.getDay());

        // Pré-conta ocorrências entre ruleStart e effStart (cursor separado)
        const preCur = new Date(preStart);
        while (preCur < effStart) {
            for (const dow of sortedDows) {
                const d = new Date(preCur);
                d.setDate(d.getDate() + dow);
                if (d >= ruleStart && d < effStart) occurrenceCount++;
            }
            preCur.setDate(preCur.getDate() + 7);
        }

        // Itera a partir da semana que contém effStart (cursor independente do preCur)
        const weekOfEffStart = new Date(effStart);
        weekOfEffStart.setDate(weekOfEffStart.getDate() - weekOfEffStart.getDay());
        const cur = new Date(weekOfEffStart);

        while (cur <= effEnd) {
            for (const dow of sortedDows) {
                const d = new Date(cur);
                d.setDate(d.getDate() + dow);
                if (d >= effStart && d <= effEnd && d >= ruleStart) {
                    if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break;
                    dates.push(new Date(d));
                    occurrenceCount++;
                }
            }
            cur.setDate(cur.getDate() + 7);
        }
    } else if (rule.type === 'monthly') {
        // mesmo dia do mês
        // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
        let occurrenceCount = 0;
        const dayOfMonth = ruleStart.getDate();

        // Pré-conta: de ruleStart até o mês anterior a effStart
        const preCur = new Date(ruleStart.getFullYear(), ruleStart.getMonth(), dayOfMonth);
        while (preCur < effStart) {
            if (preCur >= ruleStart) occurrenceCount++;
            preCur.setMonth(preCur.getMonth() + interval);
        }

        // Itera a partir do primeiro mês dentro do range
        const cur = new Date(preCur);
        while (cur <= effEnd) {
            if (cur >= ruleStart) {
                if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break;
                dates.push(new Date(cur));
                occurrenceCount++;
            }
            cur.setMonth(cur.getMonth() + interval);
        }
    } else if (rule.type === 'yearly') {
        // Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
        let occurrenceCount = 0;

        // Pré-conta: de ruleStart até o ano anterior a effStart
        const preCur = new Date(ruleStart);
        while (preCur < effStart) {
            occurrenceCount++;
            preCur.setFullYear(preCur.getFullYear() + interval);
        }

        // Itera a partir do primeiro ano dentro do range
        const cur = new Date(preCur);
        while (cur <= effEnd) {
            if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break;
            dates.push(new Date(cur));
            occurrenceCount++;
            cur.setFullYear(cur.getFullYear() + interval);
        }
    }

    return dates;
}

// ─── expansão principal ──────────────────────────────────────────────────────

// Cap defensivo: a agenda real sempre passa ranges mensais/semanais (≤42d).
// Range muito grande com muitas regras = milhares de ocorrências no browser.
// Não bloqueamos (relatórios legítimos podem precisar), só avisamos.
const MAX_RANGE_DAYS = 730; // 2 anos

/**
 * Expande regras em ocorrências, aplica exceções.
 *
 * @param {Array}  rules       - regras do banco
 * @param {Array}  exceptions  - exceções do banco (todas as das regras carregadas)
 * @param {Date}   rangeStart
 * @param {Date}   rangeEnd
 * @returns {Array} occurrences — objetos com shape compatível com FullCalendar
 */
export function expandRules(rules, exceptions, rangeStart, rangeEnd) {
    const rangeDays = Math.round((rangeEnd.getTime() - rangeStart.getTime()) / 86_400_000);
    if (rangeDays > MAX_RANGE_DAYS) {
        logError('useRecurrence', 'expandRules: range grande pode degradar UI', {
            rangeDays,
            maxRecommended: MAX_RANGE_DAYS,
            ruleCount: (rules || []).length
        });
    }

    // índice de exceções por regra+data
    const exMap = new Map();
    for (const ex of exceptions || []) {
        const key = `${ex.recurrence_id}::${ex.original_date}`;
        exMap.set(key, ex);
    }

    const occurrences = [];
    // Rastreia IDs de exceções consumidas no loop principal
    const handledExIds = new Set();

    for (const rule of rules || []) {
        if (rule.status === 'cancelado') continue;

        const dates = generateDates(rule, rangeStart, rangeEnd);

        for (const date of dates) {
            const iso = toISO(date);
            const exKey = `${rule.id}::${iso}`;
            const exception = exMap.get(exKey);

            if (exception) handledExIds.add(exception.id);

            // ── exceção: cancela esta ocorrência ──
            // cancel_session = cancel preemptivo (terapeuta apagou antes de
            // qualquer interação) → SOME da agenda (honra o "Ela some da
            // agenda" do diálogo de exclusão).
            if (exception?.type === 'cancel_session') {
                continue;
            }
            // Demais cancelamentos são HISTÓRICO (paciente faltou, terapeuta
            // cancelou após começar, feriado) → permanecem visíveis com
            // status especial pra ficar no registro.
            if (exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') {
                occurrences.push(buildOccurrence(rule, date, iso, exception));
                continue;
            }

            // ── exceção: remarca esta ocorrência ──
            if (exception?.type === 'reschedule_session') {
                const newDate = exception.new_date ? parseDate(exception.new_date) : date;
                const newIso = exception.new_date || iso;
                occurrences.push(buildOccurrence(rule, newDate, newIso, exception));
                continue;
            }

            // ── ocorrência normal ──
            occurrences.push(buildOccurrence(rule, date, iso, null));
        }
    }

    // ── post-pass: remarcações inbound ──────────────────────────────────────────
    // Cobre exceções do tipo reschedule_session cujo original_date estava FORA do
    // range (não gerado pelo loop acima) mas cujo new_date cai DENTRO do range.
    // Essas exceções chegam aqui via loadExceptions query 2, mas nunca são
    // alcançadas no loop principal — sem este post-pass o slot ficaria vazio.
    const ruleMap = new Map((rules || []).map((r) => [r.id, r]));
    const startISO = toISO(rangeStart);
    const endISO = toISO(rangeEnd);

    for (const ex of exceptions || []) {
        if (handledExIds.has(ex.id)) continue;
        if (ex.type !== 'reschedule_session') continue;
        if (!ex.new_date) continue;
        if (ex.new_date < startISO || ex.new_date > endISO) continue;

        const rule = ruleMap.get(ex.recurrence_id);
        if (!rule || rule.status === 'cancelado') continue;

        const newDate = parseDate(ex.new_date);
        occurrences.push(buildOccurrence(rule, newDate, ex.original_date, ex));
    }

    return occurrences;
}

/**
 * Constrói o objeto de ocorrência no formato que o calendário e o dialog esperam
 */
function buildOccurrence(rule, date, originalIso, exception) {
    const effectiveStartTime = exception?.new_start_time || rule.start_time;
    const effectiveEndTime = exception?.new_end_time || rule.end_time;

    const start = applyTime(date, effectiveStartTime);
    const end = applyTime(date, effectiveEndTime);

    const exType = exception?.type || null;

    return {
        // identificação
        id: `rec::${rule.id}::${originalIso}`, // id virtual
        recurrence_id: rule.id,
        original_date: originalIso,
        is_occurrence: true, // flag para diferenciar de eventos reais
        is_real_session: false,

        // dados da regra
        determined_commitment_id: rule.determined_commitment_id,
        patient_id: rule.patient_id,
        paciente_id: rule.patient_id,
        owner_id: rule.owner_id,
        therapist_id: rule.therapist_id,
        terapeuta_id: rule.therapist_id,

        // nome do paciente — injetado pelo loadAndExpand via _patient
        paciente_nome: rule._patient?.nome_completo ?? null,
        paciente_avatar: rule._patient?.avatar_url ?? null,
        patient_name: rule._patient?.nome_completo ?? null,

        // tempo
        inicio_em: start.toISOString(),
        fim_em: end.toISOString(),

        // campos opcionais
        modalidade: exception?.modalidade || rule.modalidade || 'presencial',
        titulo_custom: exception?.titulo_custom || rule.titulo_custom || null,
        observacoes: exception?.observacoes || rule.observacoes || null,
        extra_fields: exception?.extra_fields || rule.extra_fields || null,
        price: rule.price ?? null,

        // convênio — herdado da regra de recorrência
        insurance_plan_id: rule.insurance_plan_id ?? null,
        insurance_guide_number: rule.insurance_guide_number ?? null,
        insurance_value: rule.insurance_value ?? null,
        insurance_plan_service_id: rule.insurance_plan_service_id ?? null,

        // estado da exceção
        exception_type: exType,
        exception_id: exception?.id || null,
        exception_reason: exception?.reason || null,

        // status derivado da exceção
        status: _statusFromException(exType),

        // para o FullCalendar
        tipo: 'sessao'
    };
}

function _statusFromException(exType) {
    if (!exType) return 'agendado';
    if (exType === 'cancel_session') return 'cancelado';
    if (exType === 'patient_missed') return 'faltou';
    if (exType === 'therapist_canceled') return 'cancelado';
    if (exType === 'holiday_block') return 'bloqueado';
    if (exType === 'reschedule_session') return 'remarcado';
    return 'agendado';
}

/**
 * Merge ocorrências geradas com sessões reais do banco.
 * Sessões reais (is_real_session=true) sobrepõem ocorrências geradas
 * para a mesma regra+data.
 *
 * @param {Array} occurrences  - geradas por expandRules
 * @param {Array} storedRows   - linhas de agenda_eventos com recurrence_id
 * @returns {Array} merged
 */
export function mergeWithStoredSessions(occurrences, storedRows) {
    // índice de sessões reais por recurrence_id + recurrence_date
    const realMap = new Map();
    for (const row of storedRows || []) {
        if (!row.recurrence_id || !row.recurrence_date) continue;
        const key = `${row.recurrence_id}::${row.recurrence_date}`;
        realMap.set(key, {
            ...row,
            is_real_session: true,
            is_occurrence: false,
            original_date: row.original_date ?? row.recurrence_date ?? null
        });
    }

    const result = [];
    for (const occ of occurrences) {
        const key = `${occ.recurrence_id}::${occ.original_date}`;
        if (realMap.has(key)) {
            result.push(realMap.get(key));
            realMap.delete(key); // evita duplicata
        } else {
            result.push(occ);
        }
    }

    // adiciona sessões reais que não tiveram ocorrência correspondente
    // (ex: sessões avulsas ligadas a uma regra mas fora do range normal)
    for (const real of realMap.values()) {
        result.push(real);
    }

    return result;
}

// ─── composable principal ────────────────────────────────────────────────────

export function useRecurrence() {
    const rules = ref([]);
    const exceptions = ref([]);
    const loading = ref(false);
    const error = ref(null);
    const tenantStore = useTenantStore();

    function currentTenantId() {
        const tid = tenantStore.activeTenantId;
        assertTenantId(tid);
        return tid;
    }

    /**
     * Carrega regras ativas para um owner no range dado.
     * @param {string} ownerId
     * @param {Date}   rangeStart
     * @param {Date}   rangeEnd
     * @param {string|null} tenantId — se fornecido, filtra também por tenant (multi-clínica)
     */
    async function loadRules(ownerId, rangeStart, rangeEnd, tenantId = null) {
        if (!ownerId) {
            logRecurrence('loadRules: ownerId vazio, abortando');
            return;
        }
        const endPerf = logPerf('useRecurrence', 'loadRules');
        try {
            const startISO = toISO(rangeStart);
            const endISO = toISO(rangeEnd);
            logRecurrence('loadRules →', { ownerId, tenantId, startISO, endISO });

            // Busca regras sem end_date (abertas) + regras com end_date >= rangeStart
            // Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
            const baseQuery = () => {
                return tenantDb().from('recurrence_rules').select('*').eq('owner_id', ownerId).eq('status', 'ativo').lte('start_date', endISO).order('start_date', { ascending: true });
            };

            const [resOpen, resWithEnd] = await Promise.all([baseQuery().is('end_date', null), baseQuery().gte('end_date', startISO).not('end_date', 'is', null)]);

            if (resOpen.error) throw resOpen.error;
            if (resWithEnd.error) throw resWithEnd.error;

            // deduplica por id (improvável mas seguro)
            const merged = [...(resOpen.data || []), ...(resWithEnd.data || [])];
            const seen = new Set();
            rules.value = merged.filter((r) => {
                if (seen.has(r.id)) return false;
                seen.add(r.id);
                return true;
            });
            logRecurrence('loadRules ← regras encontradas', { count: rules.value.length });
            endPerf({ ruleCount: rules.value.length });
        } catch (e) {
            logError('useRecurrence', 'loadRules ERRO', e);
            error.value = e?.message || 'Erro ao carregar regras';
            rules.value = [];
        }
    }

    /**
     * Carrega exceções para as regras carregadas no range.
     *
     * Dois casos cobertos:
     *   1. original_date no range  → cobre cancels, faltas, remarcações-para-fora e remarcações-normais
     *   2. reschedule_session com new_date no range (original fora do range)
     *      → "remarcação inbound": sessão de outra semana/mês movida para cair neste range
     *
     * Ambos os resultados são mesclados e deduplicados por id.
     */
    async function loadExceptions(rangeStart, rangeEnd) {
        const ids = rules.value.map((r) => r.id);
        if (!ids.length) {
            exceptions.value = [];
            return;
        }

        try {
            const startISO = toISO(rangeStart);
            const endISO = toISO(rangeEnd);

            // Query 1 — comportamento original: exceções cujo original_date está no range
            const q1 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).gte('original_date', startISO).lte('original_date', endISO);

            // Query 2 — bug fix: remarcações cujo new_date cai neste range
            // (original_date pode estar antes ou depois do range)
            const q2 = tenantDb().from('recurrence_exceptions').select('*').in('recurrence_id', ids).eq('type', 'reschedule_session').not('new_date', 'is', null).gte('new_date', startISO).lte('new_date', endISO);

            const [res1, res2] = await Promise.all([q1, q2]);

            if (res1.error) throw res1.error;
            if (res2.error) throw res2.error;

            // Mescla e deduplica por id
            const merged = [...(res1.data || []), ...(res2.data || [])];
            const seen = new Set();
            exceptions.value = merged.filter((ex) => {
                if (seen.has(ex.id)) return false;
                seen.add(ex.id);
                return true;
            });
        } catch (e) {
            logError('useRecurrence', 'loadExceptions ERRO', e);
            error.value = e?.message || 'Erro ao carregar exceções';
            exceptions.value = [];
        }
    }

    /**
     * Carrega tudo e retorna ocorrências expandidas + merged com sessões reais.
     * @param {string}      ownerId
     * @param {Date}        rangeStart
     * @param {Date}        rangeEnd
     * @param {Array}       storedRows  — eventos reais já carregados
     * @param {string|null} tenantId    — filtra regras por tenant (multi-clínica)
     */
    async function loadAndExpand(ownerId, rangeStart, rangeEnd, storedRows = [], tenantId = null) {
        loading.value = true;
        error.value = null;
        const endPerf = logPerf('useRecurrence', 'loadAndExpand');
        logRecurrence('loadAndExpand START', { ownerId, tenantId, storedRows: storedRows.length });
        try {
            await loadRules(ownerId, rangeStart, rangeEnd, tenantId);
            await loadExceptions(rangeStart, rangeEnd);

            // Busca nomes dos pacientes das regras carregadas
            const patientIds = [...new Set(rules.value.map((r) => r.patient_id).filter(Boolean))];
            if (patientIds.length) {
                const { data: patients } = await tenantDb().from('patients').select('id, nome_completo, avatar_url').in('id', patientIds);
                // injeta nome diretamente na regra para o buildOccurrence usar
                const pMap = new Map((patients || []).map((p) => [p.id, p]));
                for (const rule of rules.value) {
                    if (rule.patient_id && pMap.has(rule.patient_id)) {
                        rule._patient = pMap.get(rule.patient_id);
                    }
                }
            }

            const occurrences = expandRules(rules.value, exceptions.value, rangeStart, rangeEnd);
            logRecurrence('expandRules → ocorrências', { count: occurrences.length });
            const merged = mergeWithStoredSessions(occurrences, storedRows);
            logRecurrence('merged final', { count: merged.length });
            endPerf({ occurrences: occurrences.length, merged: merged.length });
            return merged;
        } catch (e) {
            logError('useRecurrence', 'loadAndExpand ERRO', e);
            error.value = e?.message || 'Erro ao expandir recorrências';
            return [];
        } finally {
            loading.value = false;
        }
    }

    // ── CRUD de regras ──────────────────────────────────────────────────────────

    /**
     * Cria uma nova regra de recorrência.
     * tenant_id é dropado defensivamente — schema-per-tenant não tem essa coluna.
     * @param {Object} rule - campos da tabela recurrence_rules
     * @returns {Object} regra criada
     */
    async function createRule(rule) {
        logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type });
        const { tenant_id: _dropTenantId, ...safeRule } = rule || {};
        const { data, error: err } = await tenantDb().from('recurrence_rules').insert([safeRule]).select('*').single();
        if (err) {
            logError('useRecurrence', 'createRule ERRO', err);
            throw err;
        }
        logRecurrence('createRule ← criado', { id: data?.id });
        return data;
    }

    /**
     * Atualiza a regra toda (editar todos).
     * Isolamento multi-tenant garantido pelo schema do tenant (tenantDb).
     */
    async function updateRule(id, patch) {
        const tenantId = currentTenantId();
        const { data, error: err } = await tenantDb().from('recurrence_rules')
            .update({ ...patch, updated_at: new Date().toISOString() })
            .eq('id', id)
            
            .select('*')
            .single();
        if (err) throw err;
        return data;
    }

    /**
     * Cancela a série inteira.
     */
    async function cancelRule(id) {
        const tenantId = currentTenantId();
        const { error: err } = await tenantDb().from('recurrence_rules')
            .update({ status: 'cancelado', updated_at: new Date().toISOString() })
            .eq('id', id)
            ;
        if (err) throw err;
    }

    /**
     * Divide a série a partir de uma data (este e os seguintes)
     * Retorna o id da nova regra criada
     */
    async function splitRuleAt(id, fromDateISO) {
        const tenantId = currentTenantId();
        const { data, error: err } = await supabase.rpc('split_recurrence_at', {
            p_tenant_id: tenantId,
            p_recurrence_id: id,
            p_from_date: fromDateISO
        });
        if (err) throw err;
        return data; // new rule id
    }

    /**
     * Cancela a série a partir de uma data
     */
    async function cancelRuleFrom(id, fromDateISO) {
        const tenantId = currentTenantId();
        const { error: err } = await supabase.rpc('cancel_recurrence_from', {
            p_tenant_id: tenantId,
            p_recurrence_id: id,
            p_from_date: fromDateISO
        });
        if (err) throw err;
    }

    // ── CRUD de exceções ────────────────────────────────────────────────────────

    /**
     * Cria ou atualiza uma exceção para uma ocorrência específica.
     * tenant_id é dropado defensivamente — schema-per-tenant não tem essa coluna.
     */
    async function upsertException(ex) {
        const { tenant_id: _dropTenantId, ...safeEx } = ex || {};
        const { data, error: err } = await tenantDb().from('recurrence_exceptions')
            .upsert([safeEx], { onConflict: 'recurrence_id,original_date' })
            .select('*')
            .single();
        if (err) throw err;
        return data;
    }

    /**
     * Remove uma exceção (restaura a ocorrência ao normal).
     */
    async function deleteException(recurrenceId, originalDate) {
        const tenantId = currentTenantId();
        const { error: err } = await tenantDb().from('recurrence_exceptions')
            .delete()
            .eq('recurrence_id', recurrenceId)
            .eq('original_date', originalDate)
            ;
        if (err) throw err;
    }

    return {
        rules,
        exceptions,
        loading,
        error,

        loadRules,
        loadExceptions,
        loadAndExpand,

        createRule,
        updateRule,
        cancelRule,
        splitRuleAt,
        cancelRuleFrom,

        upsertException,
        deleteException
    };
}
