"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
    const { since } = validation_1.syncQuerySchema.parse(req.query);
    const userId = req.user.userId;
    const sinceDate = since;
    // Fetch categories changed since timestamp
    const changedCategories = await db_1.db
        .select()
        .from(schema_1.categories)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId), (0, drizzle_orm_1.gte)(schema_1.categories.updatedAt, sinceDate)))
        .orderBy((0, drizzle_orm_1.asc)(schema_1.categories.updatedAt));
    // Fetch tasks changed since timestamp
    const changedTasks = await db_1.db
        .select()
        .from(schema_1.tasks)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
        .orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
    // Fetch subtasks changed since timestamp
    let changedSubtasks = [];
    if (changedTasks.length > 0) {
        changedSubtasks = await db_1.db
            .select()
            .from(schema_1.subtasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate)))
            .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
    }
    else {
        // Also fetch subtasks for tasks that might have been deleted
        changedSubtasks = await db_1.db
            .select()
            .from(schema_1.subtasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate)))
            .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
    }
    // Fetch repeat profiles changed since timestamp
    const changedRepeatProfiles = await db_1.db
        .select()
        .from(schema_1.repeatProfiles)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId), (0, drizzle_orm_1.gte)(schema_1.repeatProfiles.updatedAt, sinceDate)))
        .orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.updatedAt));
    // Fetch friendships changed since timestamp
    const changedFriendships = await db_1.db
        .select()
        .from(schema_1.friendships)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
        .orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
    // Fetch tombstones (deletions) changed since timestamp
    const changedTombstones = await db_1.db
        .select()
        .from(schema_1.tombstones)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tombstones.updatedAt, sinceDate)))
        .orderBy((0, drizzle_orm_1.asc)(schema_1.tombstones.updatedAt));
    const timestamp = Date.now();
    res.json({
        categories: changedCategories,
        tasks: changedTasks,
        subtasks: changedSubtasks,
        repeatProfiles: changedRepeatProfiles,
        friendships: changedFriendships,
        deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
        timestamp,
    });
}));
router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
    const data = validation_1.pushChangesSchema.parse(req.body);
    const userId = req.user.userId;
    const conflicts = [];
    const timestamp = Date.now();
    try {
        await db_1.db.transaction(async (tx) => {
            // Ensure every referenced category exists (FK integrity) so a stale or
            // never-synced category reference cannot fail the entire push. Missing
            // categories are recreated as a fallback and the client heals on pull.
            const referencedCategoryIds = new Set();
            for (const task of data.changes.tasks ?? []) {
                if (task.categoryId)
                    referencedCategoryIds.add(task.categoryId);
            }
            for (const sub of data.changes.subtasks ?? []) {
                const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
                if (task?.categoryId)
                    referencedCategoryIds.add(task.categoryId);
            }
            if (referencedCategoryIds.size > 0) {
                const existing = await tx
                    .select({ id: schema_1.categories.id })
                    .from(schema_1.categories)
                    .where((0, drizzle_orm_1.inArray)(schema_1.categories.id, [...referencedCategoryIds]));
                const existingIds = new Set(existing.map((c) => c.id));
                const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
                if (missing.length > 0) {
                    const rows = await tx
                        .select({ max: (0, drizzle_orm_1.sql) `max(${schema_1.categories.order})` })
                        .from(schema_1.categories)
                        .where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId));
                    const startOrder = (rows[0]?.max ?? -1) + 1;
                    await tx.insert(schema_1.categories).values(missing.map((id, i) => ({
                        id,
                        userId,
                        name: 'Default',
                        color: '#9E9E9E',
                        order: startOrder + i,
                        createdAt: Date.now(),
                        updatedAt: Date.now(),
                    })));
                }
            }
            // Sanitize assignee references: only real user ids may be stored (FK).
            // Stale/unknown assignee ids are silently dropped to null instead of
            // failing the whole push transaction.
            const assigneeIds = new Set();
            for (const task of data.changes.tasks ?? []) {
                if (task.assigneeId)
                    assigneeIds.add(task.assigneeId);
            }
            for (const sub of data.changes.subtasks ?? []) {
                if (sub.assigneeId)
                    assigneeIds.add(sub.assigneeId);
            }
            const validAssignees = new Set();
            if (assigneeIds.size > 0) {
                const rows = await tx
                    .select({ id: schema_1.users.id })
                    .from(schema_1.users)
                    .where((0, drizzle_orm_1.inArray)(schema_1.users.id, [...assigneeIds]));
                for (const r of rows)
                    validAssignees.add(r.id);
            }
            const sanitizeAssignee = (a) => a && validAssignees.has(a) ? a : null;
            // Process categories
            if (data.changes.categories && data.changes.categories.length > 0) {
                for (const cat of data.changes.categories) {
                    const existing = await tx
                        .select()
                        .from(schema_1.categories)
                        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
                        .limit(1);
                    if (existing.length > 0) {
                        // Check for conflict
                        if (existing[0].updatedAt > cat.updatedAt) {
                            conflicts.push({
                                entity: 'categories',
                                id: cat.id,
                                serverVersion: existing[0],
                                clientVersion: cat,
                                resolution: 'server_wins',
                            });
                            continue; // Server wins
                        }
                        await tx
                            .update(schema_1.categories)
                            .set({
                            name: cat.name,
                            color: cat.color,
                            order: cat.order,
                            updatedAt: cat.updatedAt,
                        })
                            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
                    }
                    else {
                        await tx.insert(schema_1.categories).values({
                            ...cat,
                            userId,
                        });
                    }
                }
            }
            // Process tasks
            if (data.changes.tasks && data.changes.tasks.length > 0) {
                for (const task of data.changes.tasks) {
                    const existing = await tx
                        .select()
                        .from(schema_1.tasks)
                        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
                        .limit(1);
                    if (existing.length > 0) {
                        // Check for conflict
                        if (existing[0].updatedAt > task.updatedAt) {
                            conflicts.push({
                                entity: 'tasks',
                                id: task.id,
                                serverVersion: existing[0],
                                clientVersion: task,
                                resolution: 'server_wins',
                            });
                            continue; // Server wins
                        }
                        // Prevent completing tasks with future due dates
                        if (task.completed === true) {
                            const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
                            if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
                                conflicts.push({
                                    entity: 'tasks',
                                    id: task.id,
                                    serverVersion: existing[0],
                                    clientVersion: task,
                                    resolution: 'server_wins',
                                });
                                continue;
                            }
                        }
                        await tx
                            .update(schema_1.tasks)
                            .set({
                            title: task.title,
                            description: task.description,
                            categoryId: task.categoryId,
                            priority: task.priority,
                            completed: task.completed,
                            dueDate: task.dueDate,
                            dueTime: task.dueTime ?? '',
                            endTime: task.endTime ?? '',
                            allDay: task.allDay ?? false,
                            repeat: task.repeat ?? 'none',
                            repeatInterval: task.repeatInterval ?? 1,
                            repeatDays: task.repeatDays ?? '',
                            seriesId: task.seriesId ?? '',
                            reminder: task.reminder ?? 'none',
                            reminders: task.reminders ?? '',
                            assigneeId: sanitizeAssignee(task.assigneeId),
                            completedAt: task.completedAt ?? null,
                            updatedAt: task.updatedAt,
                        })
                            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
                    }
                    else {
                        await tx.insert(schema_1.tasks).values({
                            ...task,
                            assigneeId: sanitizeAssignee(task.assigneeId),
                            allDay: task.allDay ?? false,
                            userId,
                        });
                    }
                }
            }
            // Process subtasks
            if (data.changes.subtasks && data.changes.subtasks.length > 0) {
                for (const sub of data.changes.subtasks) {
                    const existing = await tx
                        .select()
                        .from(schema_1.subtasks)
                        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
                        .limit(1);
                    if (existing.length > 0) {
                        // Check for conflict
                        if (existing[0].updatedAt > sub.updatedAt) {
                            conflicts.push({
                                entity: 'subtasks',
                                id: sub.id,
                                serverVersion: existing[0],
                                clientVersion: sub,
                                resolution: 'server_wins',
                            });
                            continue; // Server wins
                        }
                        await tx
                            .update(schema_1.subtasks)
                            .set({
                            taskId: sub.taskId,
                            parentSubtaskId: sub.parentSubtaskId ?? null,
                            title: sub.title,
                            description: sub.description ?? '',
                            priority: sub.priority ?? 'none',
                            completed: sub.completed,
                            dueDate: sub.dueDate ?? 0,
                            dueTime: sub.dueTime ?? '',
                            endTime: sub.endTime ?? '',
                            allDay: sub.allDay ?? false,
                            repeat: sub.repeat ?? 'none',
                            repeatInterval: sub.repeatInterval ?? 1,
                            repeatDays: sub.repeatDays ?? '',
                            seriesId: sub.seriesId ?? '',
                            reminder: sub.reminder ?? 'none',
                            reminders: sub.reminders ?? '',
                            assigneeId: sanitizeAssignee(sub.assigneeId),
                            order: sub.order,
                            updatedAt: sub.updatedAt,
                        })
                            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
                    }
                    else {
                        await tx.insert(schema_1.subtasks).values({
                            ...sub,
                            assigneeId: sanitizeAssignee(sub.assigneeId),
                            userId,
                        });
                    }
                }
            }
            // Process repeat profiles
            if (data.changes.repeatProfiles && data.changes.repeatProfiles.length > 0) {
                for (const profile of data.changes.repeatProfiles) {
                    const existing = await tx
                        .select()
                        .from(schema_1.repeatProfiles)
                        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
                        .limit(1);
                    if (existing.length > 0) {
                        // Check for conflict
                        if (existing[0].updatedAt > profile.updatedAt) {
                            conflicts.push({
                                entity: 'repeatProfiles',
                                id: profile.id,
                                serverVersion: existing[0],
                                clientVersion: profile,
                                resolution: 'server_wins',
                            });
                            continue; // Server wins
                        }
                        await tx
                            .update(schema_1.repeatProfiles)
                            .set({
                            name: profile.name,
                            repeat: profile.repeat,
                            repeatInterval: profile.repeatInterval,
                            repeatDays: profile.repeatDays,
                            updatedAt: profile.updatedAt,
                        })
                            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
                    }
                    else {
                        await tx.insert(schema_1.repeatProfiles).values({
                            ...profile,
                            userId,
                        });
                    }
                }
            }
            // Process friendships
            if (data.changes.friendships && data.changes.friendships.length > 0) {
                for (const friendship of data.changes.friendships) {
                    const existing = await tx
                        .select()
                        .from(schema_1.friendships)
                        .where((0, drizzle_orm_1.eq)(schema_1.friendships.id, friendship.id))
                        .limit(1);
                    if (existing.length > 0) {
                        // Check for conflict
                        if (existing[0].updatedAt > friendship.updatedAt) {
                            conflicts.push({
                                entity: 'friendships',
                                id: friendship.id,
                                serverVersion: existing[0],
                                clientVersion: friendship,
                                resolution: 'server_wins',
                            });
                            continue; // Server wins
                        }
                        await tx
                            .update(schema_1.friendships)
                            .set({
                            userId: friendship.userId,
                            friendId: friendship.friendId,
                            status: friendship.status,
                            updatedAt: friendship.updatedAt,
                        })
                            .where((0, drizzle_orm_1.eq)(schema_1.friendships.id, friendship.id));
                    }
                    else {
                        await tx.insert(schema_1.friendships).values(friendship);
                    }
                }
            }
            // Process deletions (tombstones) - last so they see the state produced
            // by the upserts above and resolve by last-writer-wins.
            if (data.deleted && data.deleted.length > 0) {
                for (const deleted of data.deleted) {
                    await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
                }
            }
        });
    }
    catch (error) {
        console.error('Sync push error:', error);
        throw new errorHandler_1.AppError('SERVER_ERROR', 'Failed to process sync push', 500);
    }
    res.json({
        success: true,
        timestamp,
        conflicts,
    });
}));
// Upsert a tombstone row, keeping the latest updatedAt.
async function upsertTombstone(tx, entity, entityId, updatedAt, userId) {
    const existing = await tx
        .select({ updatedAt: schema_1.tombstones.updatedAt })
        .from(schema_1.tombstones)
        .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)))
        .limit(1);
    const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
    if (existing.length > 0) {
        await tx
            .update(schema_1.tombstones)
            .set({ updatedAt: merged })
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)));
    }
    else {
        await tx.insert(schema_1.tombstones).values({ entity, entityId, userId, updatedAt: merged });
    }
}
// Apply a client deletion. LWW: if the server row is newer than the deletion
// timestamp, the deletion is rejected (server_wins conflict) so the client
// re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded
// child so all devices remove them too.
async function applyTombstone(tx, entity, id, deletedAt, userId, conflicts) {
    const tombstoneOf = (e, ids) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
    if (entity === 'tasks') {
        const row = await tx
            .select()
            .from(schema_1.tasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
            .limit(1);
        if (row.length === 0) {
            await upsertTombstone(tx, entity, id, deletedAt, userId);
            return;
        }
        if (row[0].updatedAt > deletedAt) {
            conflicts.push({
                entity: 'tasks',
                id,
                serverVersion: row[0],
                clientVersion: { id, updatedAt: deletedAt },
                resolution: 'server_wins',
            });
            return;
        }
        const children = await tx
            .select({ id: schema_1.subtasks.id })
            .from(schema_1.subtasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
        tombstoneOf('subtasks', children.map((c) => c.id));
        await tx.delete(schema_1.tasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
        await upsertTombstone(tx, entity, id, deletedAt, userId);
        return;
    }
    if (entity === 'categories') {
        const row = await tx
            .select()
            .from(schema_1.categories)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
            .limit(1);
        if (row.length === 0) {
            await upsertTombstone(tx, entity, id, deletedAt, userId);
            return;
        }
        if (row[0].updatedAt > deletedAt) {
            conflicts.push({
                entity: 'categories',
                id,
                serverVersion: row[0],
                clientVersion: { id, updatedAt: deletedAt },
                resolution: 'server_wins',
            });
            return;
        }
        // Deleting the category cascades its tasks (and their subtasks) -
        // tombstone all of them so every client removes them.
        const catTasks = await tx
            .select()
            .from(schema_1.tasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
        for (const taskRow of catTasks) {
            const subIds = await tx
                .select({ id: schema_1.subtasks.id })
                .from(schema_1.subtasks)
                .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskRow.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
            tombstoneOf('subtasks', subIds.map((s) => s.id));
            tombstoneOf('tasks', [taskRow.id]);
        }
        await tx.delete(schema_1.categories).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
        await upsertTombstone(tx, entity, id, deletedAt, userId);
        return;
    }
    if (entity === 'subtasks') {
        const row = await tx
            .select()
            .from(schema_1.subtasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
            .limit(1);
        if (row.length === 0) {
            await upsertTombstone(tx, entity, id, deletedAt, userId);
            return;
        }
        if (row[0].updatedAt > deletedAt) {
            conflicts.push({
                entity: 'subtasks',
                id,
                serverVersion: row[0],
                clientVersion: { id, updatedAt: deletedAt },
                resolution: 'server_wins',
            });
            return;
        }
        // Deleting a parent subtask cascades its children in PG - tombstone them.
        const children = await tx
            .select({ id: schema_1.subtasks.id })
            .from(schema_1.subtasks)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
        tombstoneOf('subtasks', children.map((c) => c.id));
        await tx.delete(schema_1.subtasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
        await upsertTombstone(tx, entity, id, deletedAt, userId);
        return;
    }
    if (entity === 'repeatProfiles') {
        const row = await tx
            .select()
            .from(schema_1.repeatProfiles)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
            .limit(1);
        if (row.length === 0) {
            await upsertTombstone(tx, entity, id, deletedAt, userId);
            return;
        }
        if (row[0].updatedAt > deletedAt) {
            conflicts.push({
                entity: 'repeatProfiles',
                id,
                serverVersion: row[0],
                clientVersion: { id, updatedAt: deletedAt },
                resolution: 'server_wins',
            });
            return;
        }
        await tx.delete(schema_1.repeatProfiles).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
        await upsertTombstone(tx, entity, id, deletedAt, userId);
        return;
    }
    if (entity === 'friendships') {
        const row = await tx
            .select()
            .from(schema_1.friendships)
            .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.id, id), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId))))
            .limit(1);
        if (row.length === 0) {
            await upsertTombstone(tx, entity, id, deletedAt, userId);
            return;
        }
        if (row[0].updatedAt > deletedAt) {
            conflicts.push({
                entity: 'friendships',
                id,
                serverVersion: row[0],
                clientVersion: { id, updatedAt: deletedAt },
                resolution: 'server_wins',
            });
            return;
        }
        await tx.delete(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, id));
        await upsertTombstone(tx, entity, id, deletedAt, userId);
        return;
    }
}
exports.default = router;
//# sourceMappingURL=sync.js.map