import {
    MongoClient,
    Collection,
    Document,
    MongoClientOptions,
    ObjectId,
} from "mongodb";
import dotenv from "dotenv";
import { getISOWeek, getISOWeekYear, subWeeks } from "date-fns";

// Load environment variables
dotenv.config();

// Define MongoDB client options
const mongoOptions: MongoClientOptions = {
    connectTimeoutMS: 30000,
    maxPoolSize: 20, // prevent excessive parallel connections
    maxIdleTimeMS: 60000,
};

// Lazy getters for env vars — only validated when actually needed (not at import time).
// This allows the module to be imported during build without throwing.
function getDbUri(): string {
    const uri = process.env.MONGODB_URI || "";
    if (!uri) {
        throw new Error("MONGODB_URI must be set in the environment");
    }
    return uri;
}

function getDbName(): string {
    const name = process.env.DB_NAME || "";
    if (!name) {
        throw new Error("DB_NAME must be set in the environment");
    }
    return name;
}

// --- Global connection cache (important for serverless) ---
declare global {
    // eslint-disable-next-line no-var
    var _mongoClient: MongoClient | undefined;
}

async function getMongoClient(): Promise<MongoClient> {
    if (global._mongoClient) return global._mongoClient;

    console.log("🌱 Connecting to MongoDB...");
    const client = new MongoClient(getDbUri(), mongoOptions);
    await client.connect();
    global._mongoClient = client;
    return client;
}

// --- Main class ---
interface ReadAllOptions {
    /**
     * Optional sort object, e.g. { createdAt: 1 } for ascending or { createdAt: -1 } for descending
     */
    sort?: Document;
    limit?: number;
    skip?: number;
}

/**
 * Class representing a MongoDB client for a specific collection.
 */
class ClientDB {
    /**
     * Returns the native MongoDB Db instance from the global connection.
     * Useful for libraries that require a direct Db object (e.g. Better-Auth).
     */
    static async getNativeDb() {
        const client = await getMongoClient();
        return client.db(getDbName());
    }

    /**
     * Returns the native MongoDB Db instance for this instance's connection.
     */
    async getNativeDb() {
        await this.connect();
        return this.client.db(getDbName());
    }

    /**
     * Returns the native MongoDB Db instance synchronously.
     * The driver will handle connection queuing in the background.
     */
    static getNativeDbSync() {
        if (!global._mongoClient) {
            console.log("🌱 Initializing synchronous MongoDB connection...");
            global._mongoClient = new MongoClient(getDbUri(), mongoOptions);
        }
        return global._mongoClient.db(getDbName());
    }

    private client!: MongoClient;
    private collectionName: string;
    private collection: Collection | null;
    /**
     * Creates an instance of ClientDB.
     * @param {string} collectionName - The name of the collection to interact with.
     */
    constructor(collectionName: string) {
        // Use the validated DB_URI and predefined options
        this.collectionName = collectionName;
        this.collection = null;
    }

    /**
     * Connects to the MongoDB database and initializes the collection.
     * @returns {Promise<void>}
     */
    async connect(): Promise<void> {
        if (!this.collection) {
            this.client = await getMongoClient();
            const db = this.client.db(getDbName());
            this.collection = db.collection(this.collectionName);
        }
    }

    /**
     * Helper to convert _id string to ObjectId if needed
     * @param {Document} query - The query object to preprocess
     * @returns {Document} The processed query with _id converted if applicable
     */
    private preprocessQuery(query: Document): Document {
        if (query._id && typeof query._id === "string") {
            try {
                query._id = new ObjectId(query._id);
            } catch {
                // Invalid ObjectId string, leave as is or handle error if you prefer
            }
        }
        return query;
    }

    /**
     * Reads a document from the collection based on the provided query.
     * @param {Document} query - The query to find the document.
     * @returns {Promise<Document|null>} The found document or null if not found.
     */
    async read(query: Document): Promise<Document | null> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.findOne(processedQuery);
    }

    /**
     * Reads all documents from the collection with optional sorting.
     * @param {ReadAllOptions} [options] - Optional options object.
     * @param {Document} [options.sort] - Optional sort object, e.g. { createdAt: 1 } for ascending.
     * @returns {Promise<Document[]>} An array of all documents, optionally sorted.
     */
    async readAll(options?: ReadAllOptions): Promise<Document[]> {
        await this.connect();

        let cursor = this.collection!.find();

        if (options?.sort) {
            cursor = cursor.sort(options.sort);
        }
        if (options?.limit) {
            cursor = cursor.limit(options.limit);
        }
        if (options?.skip) {
            cursor = cursor.skip(options.skip);
        }

        return await cursor.toArray();
    }

    /**
     * Inserts a new document into the collection.
     * @param {Document} data - The data to insert.
     * @returns {Promise<Document>} The result of the insert operation.
     */
    async insert(data: Document): Promise<Document> {
        await this.connect();
        return await this.collection!.insertOne(data);
    }

    /**
     * Inserts multiple documents into the collection.
     * @param {Document[]} data - The data to insert.
     * @returns {Promise<Document>} The result of the insert operation.
     */
    async insertMany(data: Document[]): Promise<Document> {
        await this.connect();
        return await this.collection!.insertMany(data);
    }

    /**
     * Updates a document in the collection based on the provided query.
     * @param {Document} query - The query to find the document to update.
     * @param {Document} data - The data to update.
     * @returns {Promise<Document>} The result of the update operation.
     */
    async update(query: Document, data: Document): Promise<Document> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.updateOne(processedQuery, { $set: data });
    }

    /**
     * Updates multiple documents in the collection.
     * - If you pass Mongo operators ($set, $inc, etc), it will use them directly.
     * - If you pass a plain object, it will wrap it inside $set.
     *
     * @param {Document} query - The filter to match documents.
     * @param {Document} data - The update data (plain object or with operators).
     * @returns {Promise<Document>} The result of the update operation.
     */
    async updateMany(
        query: Document,
        data: Document | Document[],
        options: { upsert?: boolean } = {},
    ): Promise<Document> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);

        const hasOperator =
            !Array.isArray(data) &&
            Object.keys(data).some((key) => key.startsWith("$"));
        const updateDoc = Array.isArray(data)
            ? data
            : hasOperator
              ? data
              : { $set: data };

        return await this.collection!.updateMany(processedQuery, updateDoc, {
            upsert: options.upsert ?? false,
        });
    }

    /**
     * Deletes a document from the collection based on the provided query.
     * @param {Document} query - The query to find the document to delete.
     * @returns {Promise<Document>} The result of the delete operation.
     */
    async delete(query: Document): Promise<Document> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.deleteOne(processedQuery);
    }

    /**
     * Deletes multiple documents from the collection based on the provided query.
     * @param {Document} query - The query to find the documents to delete.
     * @returns {Promise<Document>} The result of the delete operation.
     */
    async deleteMany(query: Document): Promise<Document> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.deleteMany(processedQuery);
    }

    /**
     * Finds multiple documents in the collection based on the provided query.
     * @param {Document} query - The query to find the documents.
     * @returns {Promise<Document[]>} An array of found documents.
     */
    async find(
        query: Document,
        options?: ReadAllOptions,
        project?: Document,
    ): Promise<Document[]> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);

        let cursor = this.collection!.find(
            processedQuery,
            project ? { projection: project } : undefined,
        );

        if (options?.sort) {
            cursor = cursor.sort(options.sort);
        }
        if (options?.limit) {
            cursor = cursor.limit(options.limit);
        }
        if (options?.skip) {
            cursor = cursor.skip(options.skip);
        }

        return await cursor.toArray();
    }

    /**
     * Reads multiple documents from the collection based on a query.
     * @param {Document} query - The filter query.
     * @returns {Promise<Document[]>} Array of matching documents.
     */
    async readMany(query: Document): Promise<Document[]> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.find(processedQuery).toArray();
    }

    /**
     * Gets random documents from the collection.
     * @param {number} [total=1] - The number of random documents to retrieve.
     * @returns {Promise<Document[]>} An array of random documents.
     */
    async getRandomData(total: number = 1): Promise<Document[]> {
        await this.connect();
        const pipeline = [{ $sample: { size: total } }];
        return await this.collection!.aggregate(pipeline).toArray();
    }

    /**
     * Run an aggregation pipeline on the current collection.
     *
     * @param {Array<Document>} pipeline - An array of MongoDB aggregation stages.
     *   Example:
     *   [
     *     { $unwind: "$tags" },
     *     { $group: { _id: "$tags", count: { $sum: 1 } } },
     *     { $sort: { count: -1 } },
     *     { $limit: 10 }
     *   ]
     *
     * @returns {Promise<Document[]>} Resolves with the array of aggregation results.
     *
     * @throws {Error} If the aggregation query fails.
     */
    async aggregate(pipeline: Document[] = []): Promise<Document[]> {
        try {
            await this.connect(); // ensure connected
            return await this.collection!.aggregate(pipeline).toArray();
        } catch (err) {
            console.error("Aggregate error:", err);
            throw err;
        }
    }

    /**
     * Gets the storage statistics for the collection.
     * @returns {Promise<{storageSize: number, size: number, count: number}>} The storage statistics including storageSize.
     */
    async getStorageStats(): Promise<{
        storageSize: number;
        size: number;
        count: number;
    }> {
        await this.connect();
        const stats = await this.client
            .db(getDbName())
            .command({ collStats: this.collectionName });
        return {
            storageSize: stats.storageSize,
            size: stats.size,
            count: stats.count,
        };
    }

    /**
     * Gets cluster-wide storage statistics.
     */
    static async getClusterStats(): Promise<{
        totalDataSize: number;
        totalStorageSize: number;
        totalIndexSize: number;
        databases: any[];
    }> {
        const client = await getMongoClient();
        const admin = client.db().admin();
        const dbList = await admin.listDatabases();

        let totalDataSize = 0;
        let totalStorageSize = 0;
        let totalIndexSize = 0;
        const databases = [];

        for (const dbInfo of dbList.databases) {
            // Skip system databases that often have restricted access
            if (["admin", "local", "config"].includes(dbInfo.name)) {
                continue;
            }

            const db = client.db(dbInfo.name);
            try {
                const stats = await db.command({ dbStats: 1 });

                totalDataSize += stats.dataSize || 0;
                totalStorageSize += stats.storageSize || 0;
                totalIndexSize += stats.indexSize || 0;

                databases.push({
                    name: dbInfo.name,
                    dataSize: stats.dataSize,
                    storageSize: stats.storageSize,
                    indexSize: stats.indexSize,
                    collections: stats.collections,
                    objects: stats.objects,
                });
            } catch (err) {
                // Only log if it's not an authorization error to keep logs clean
                if (!(err as any).message?.includes("Unauthorized")) {
                    console.error(
                        `Error getting stats for db ${dbInfo.name}:`,
                        err,
                    );
                }
                databases.push({
                    name: dbInfo.name,
                    error: (err as Error).message,
                });
            }
        }

        return {
            totalDataSize,
            totalStorageSize,
            totalIndexSize,
            databases,
        };
    }

    /**
     * Closes the MongoDB connection.
     * @returns {Promise<void>}
     */
    async close(): Promise<void> {
        await this.client.close();
    }

    /**
     * Finds documents in the current collection and dynamically joins related collections
     * using MongoDB's `$lookup` aggregation stage.
     *
     * This is useful when you want to "populate" data from other collections
     * (similar to Mongoose's populate) but in a flexible, dynamic way.
     *
     * ### Example:
     * ```ts
     * const tasks = await tasksDb.findWithRelations(
     *   { column: "todo" }, // filter
     *   [
     *     {
     *       from: "kanban_tags",
     *       localField: "tags",
     *       foreignField: "value",
     *       as: "tags"
     *     },
     *     {
     *       from: "kanban_persons",
     *       localField: "persons",
     *       foreignField: "_id",
     *       as: "persons",
     *       isObjectId: true // convert string IDs to ObjectId
     *     }
     *   ],
     *   {
     *     title: 1,
     *     description: 1,
     *     "tags.label": 1,
     *     "persons.name": 1
     *   },
     *   {
     *     sort: { createdAt: -1 },
     *     limit: 10,
     *     skip: 20
     *   }
     * );
     * ```
     *
     * @param {Document} [filter={}] - MongoDB query filter. Defaults to `{}` (fetch all).
     * @param {Object[]} [relations=[]] - Array of relation configurations for `$lookup`.
     * @param {string} relations[].from - Target collection name to join with.
     * @param {string} relations[].localField - Field in this collection that holds the reference.
     * @param {string} relations[].foreignField - Field in the target collection to match against.
     * @param {string} relations[].as - The alias name for the joined data in the output.
     * @param {boolean} [relations[].isObjectId] - If `true`, will map string IDs in `localField` into ObjectId before lookup.
     * @param {Document} [project={}] - Optional MongoDB projection object to limit fields in the final output.
     * @param {ReadAllOptions} [options={}] - Optional settings like sort, skip, and limit.
     *
     * @returns {Promise<Document[]>} A promise that resolves to an array of documents with joined relations applied.
     */
    async findWithRelations(
        filter: Document = {},
        relations: {
            from: string;
            localField: string;
            foreignField: string;
            as: string;
            isObjectId?: boolean;
            isSingle?: boolean; // 👈 NEW
        }[] = [],
        project: Document = {},
        options?: ReadAllOptions,
    ): Promise<Document[]> {
        await this.connect();
        const processedQuery = this.preprocessQuery(filter);

        const pipeline: Document[] = [];

        if (Object.keys(processedQuery).length > 0) {
            pipeline.push({ $match: processedQuery });
        }

        for (const rel of relations) {
            if (rel.isObjectId) {
                if (rel.isSingle) {
                    // 👇 Single string → ObjectId
                    pipeline.push({
                        $addFields: {
                            [rel.localField]: {
                                $toObjectId: `$${rel.localField}`,
                            },
                        },
                    });
                } else {
                    // 👇 Array of strings → map to ObjectIds
                    pipeline.push({
                        $addFields: {
                            [rel.localField]: {
                                $map: {
                                    input: `$${rel.localField}`,
                                    as: "id",
                                    in: { $toObjectId: "$$id" },
                                },
                            },
                        },
                    });
                }
            }

            pipeline.push({
                $lookup: {
                    from: rel.from,
                    localField: rel.localField,
                    foreignField: rel.foreignField,
                    as: rel.as,
                },
            });
        }

        if (options?.sort) pipeline.push({ $sort: options.sort });
        if (options?.skip) pipeline.push({ $skip: options.skip });
        if (options?.limit) pipeline.push({ $limit: options.limit });

        if (Object.keys(project).length > 0) {
            pipeline.push({ $project: project });
        }

        return await this.collection!.aggregate(pipeline).toArray();
    }

    /**
     * Finds a single document in the current collection and dynamically joins related collections
     * using MongoDB's `$lookup` aggregation stage.
     *
     * Mirip dengan `findWithRelations`, tapi hanya return satu dokumen (bukan array).
     *
     * ### Example:
     * ```ts
     * const task = await tasksDb.findOneWithRelations(
     *   { _id: "66cfa89f3c9c7d776b5f4f10" },
     *   [
     *     {
     *       from: "kanban_tags",
     *       localField: "tags",
     *       foreignField: "value",
     *       as: "tags"
     *     },
     *     {
     *       from: "kanban_persons",
     *       localField: "persons",
     *       foreignField: "_id",
     *       as: "persons",
     *       isObjectId: true
     *     }
     *   ]
     * );
     * ```
     *
     * @param {Document} filter - MongoDB query filter. Biasanya pakai `_id`.
     * @param {Object[]} [relations=[]] - Array of relation configs sama seperti `findWithRelations`.
     * @param {Document} [project={}] - Projection untuk limit field output.
     *
     * @returns {Promise<Document | null>} A single document with joined relations, or `null`.
     */
    async findOneWithRelations(
        filter: Document,
        relations: {
            from: string;
            localField: string;
            foreignField: string;
            as: string;
            isObjectId?: boolean;
            isSingle?: boolean;
        }[] = [],
        project: Document = {},
    ): Promise<Document | null> {
        await this.connect();
        const processedQuery = this.preprocessQuery(filter);

        const pipeline: Document[] = [{ $match: processedQuery }];

        // relations
        for (const rel of relations) {
            if (rel.isObjectId) {
                if (rel.isSingle) {
                    pipeline.push({
                        $addFields: {
                            [rel.localField]: {
                                $toObjectId: `$${rel.localField}`,
                            },
                        },
                    });
                } else {
                    pipeline.push({
                        $addFields: {
                            [rel.localField]: {
                                $map: {
                                    input: `$${rel.localField}`,
                                    as: "id",
                                    in: { $toObjectId: "$$id" },
                                },
                            },
                        },
                    });
                }
            }

            pipeline.push({
                $lookup: {
                    from: rel.from,
                    localField: rel.localField,
                    foreignField: rel.foreignField,
                    as: rel.as,
                },
            });
        }

        if (Object.keys(project).length > 0) {
            pipeline.push({ $project: project });
        }

        const results = await this.collection!.aggregate(pipeline).toArray();
        return results[0] || null;
    }

    /**
     * Counts the number of documents matching the query.
     * Alias for countDocuments.
     * @param {Document} [query={}] - Optional filter query.
     * @returns {Promise<number>} The count of matching documents.
     */
    async count(query: Document = {}): Promise<number> {
        return this.countDocuments(query);
    }

    /**
     * Counts the number of documents matching the query.
     * @param {Document} [query={}] - Optional filter query.
     * @returns {Promise<number>} The count of matching documents.
     */
    async countDocuments(query: Document = {}): Promise<number> {
        await this.connect();
        const processedQuery = this.preprocessQuery(query);
        return await this.collection!.countDocuments(processedQuery);
    }

    /**
     * One-time migration: convert string date fields to Date objects.
     *
     * @param fields - Which fields to convert (default: createdAt, updatedAt, startAt, endAt)
     * @returns number of documents updated
     */
    async migrateDateFields(
        fields: string[] = ["createdAt", "updatedAt", "startAt", "endAt"],
    ): Promise<number> {
        await this.connect();

        const cursor = this.collection!.find({
            $or: fields.map((f) => ({ [f]: { $type: "string" } })),
        });

        let count = 0;
        while (await cursor.hasNext()) {
            const doc = await cursor.next();
            if (!doc) continue; // TS happy + runtime safe

            const updates: Record<string, any> = {};
            for (const field of fields) {
                if (typeof doc[field] === "string") {
                    updates[field] = new Date(doc[field]);
                }
            }

            if (Object.keys(updates).length > 0) {
                await this.collection!.updateOne(
                    { _id: doc._id },
                    { $set: updates },
                );
                count++;
            }
        }

        return count;
    }
    /**
     * INTERNAL: Connect to external cluster
     */
    private static async _connectExternal(uri: string) {
        const { MongoClient } = require("mongodb");
        const client = new MongoClient(uri, {
            maxPoolSize: 20,
            connectTimeoutMS: 30000,
        });
        await client.connect();
        return client;
    }

    /**
     * INCREMENTAL BACKUP (NO DELETE):
     * - Hanya ambil dokumen yang updatedAt > lastBackupAt
     * - Tidak pernah hapus dokumen di backup
     * - Upsert-only
     */
    static async incrementalBackupOneDatabase(params: {
        targetUri: string;
        sourceDb: string;
        targetDb: string;
    }) {
        const { targetUri, sourceDb, targetDb } = params;

        const sourceClient = await ClientDB._connectExternal(
            process.env.MONGODB_URI!,
        );
        const targetClient = await ClientDB._connectExternal(targetUri);

        const src = sourceClient.db(sourceDb);
        const tgt = targetClient.db(targetDb);

        const metaCol = tgt.collection("_backup_meta");
        const meta = await metaCol.findOne({ _id: "incremental" });

        const lastBackupAt = meta?.lastBackupAt
            ? new Date(meta.lastBackupAt)
            : new Date(0);

        const now = new Date();
        const collections = await src.listCollections().toArray();
        const report: any[] = [];

        for (const c of collections) {
            const name = c.name;
            if (name === "_backup_meta") continue;

            const srcCol = src.collection(name);
            const tgtCol = tgt.collection(name);

            // ambil dokumen yang berubah
            const changedDocs = await srcCol
                .find({ updatedAt: { $gt: lastBackupAt } })
                .toArray();

            for (const doc of changedDocs) {
                const { _id, ...data } = doc;
                await tgtCol.updateOne(
                    { _id: _id },
                    { $set: data },
                    { upsert: true },
                );
            }

            report.push({
                collection: name,
                upserted: changedDocs.length,
            });
        }

        // update checkpoint
        await metaCol.updateOne(
            { _id: "incremental" },
            { $set: { lastBackupAt: now } },
            { upsert: true },
        );

        await sourceClient.close();
        await targetClient.close();

        return {
            mode: "incremental",
            sourceDb,
            targetDb,
            lastBackupAt,
            executedAt: now,
            report,
        };
    }

    /**
     * MULTI-DATABASE INCREMENTAL (NO DELETE)
     */
    static async incrementalBackupManyDatabases(
        targetUri: string,
        dbList: string[],
    ) {
        const allResults: any[] = [];

        for (const dbName of dbList) {
            const r = await ClientDB.incrementalBackupOneDatabase({
                targetUri,
                sourceDb: dbName,
                targetDb: dbName,
            });
            allResults.push(r);
        }

        return {
            mode: "incremental",
            targetUri,
            results: allResults,
        };
    }

    /**
     * DELTA BACKUP (NO DELETE):
     * - hanya dokumen baru (yang belum ada _id nya di backup)
     * - tidak pernah hapus
     */
    static async deltaBackupOneDatabase(params: {
        targetUri: string;
        sourceDb: string;
        targetDb: string;
    }) {
        const { targetUri, sourceDb, targetDb } = params;

        const sourceClient = await ClientDB._connectExternal(
            process.env.MONGODB_URI!,
        );
        const targetClient = await ClientDB._connectExternal(targetUri);

        const src = sourceClient.db(sourceDb);
        const tgt = targetClient.db(targetDb);

        const collections = await src.listCollections().toArray();
        const report: any[] = [];

        for (const c of collections) {
            const name = c.name;
            const srcCol = src.collection(name);
            const tgtCol = tgt.collection(name);

            // semua _id di backup
            const tgtDocs = await tgtCol
                .find({}, { projection: { _id: 1 } })
                .toArray();
            const tgtIds = tgtDocs.map((d: Document) => d._id);

            // dokumen yang belum ada
            const newDocs = await srcCol
                .find({
                    _id: { $nin: tgtIds },
                })
                .toArray();

            if (newDocs.length) {
                await tgtCol.insertMany(newDocs);
            }

            report.push({
                collection: name,
                inserted: newDocs.length,
            });
        }

        await sourceClient.close();
        await targetClient.close();

        return {
            mode: "delta",
            sourceDb,
            targetDb,
            report,
        };
    }

    /**
     * MULTI-DATABASE DELTA (NO DELETE)
     */
    static async deltaBackupManyDatabases(targetUri: string, dbList: string[]) {
        const allResults: any[] = [];

        for (const dbName of dbList) {
            const r = await ClientDB.deltaBackupOneDatabase({
                targetUri,
                sourceDb: dbName,
                targetDb: dbName,
            });
            allResults.push(r);
        }

        return {
            mode: "delta",
            targetUri,
            results: allResults,
        };
    }
    static async fullSyncOneDatabase(params: {
        sourceUri: string;
        targetUri: string;
        dbName: string;
    }) {
        const { sourceUri, targetUri, dbName } = params;

        const now = new Date();
        const week = getISOWeek(now);
        const year = getISOWeekYear(now);
        const snapshotDbName = `${dbName}-week-${week}-${year}`;

        const srcClient = await ClientDB._connectExternal(sourceUri);
        const tgtClient = await ClientDB._connectExternal(targetUri);

        const src = srcClient.db(dbName);
        const tgt = tgtClient.db(snapshotDbName);

        const collections = await src.listCollections().toArray();

        const report: any[] = [];
        let totalInserted = 0;
        let totalUpdated = 0;
        let totalDeleted = 0;

        for (const col of collections) {
            const name = col.name;
            const srcCol = src.collection(name);
            const tgtCol = tgt.collection(name);

            // --- Load all docs (lean, no heavy fields)
            const srcDocs = await srcCol.find({}).toArray();
            const tgtDocs = await tgtCol
                .find({}, { projection: { _id: 1, updatedAt: 1 } })
                .toArray();

            const srcMap = new Map<string, any>();
            const tgtMap = new Map<string, any>();

            type MongoDoc = { _id: any; updatedAt?: any; [key: string]: any };

            srcDocs.forEach((d: MongoDoc) => srcMap.set(String(d._id), d));
            tgtDocs.forEach((d: MongoDoc) => tgtMap.set(String(d._id), d));

            const inserts: any[] = [];
            const updates: any[] = [];
            const deletes: ObjectId[] = [];

            // --- Compute inserts & updates
            for (const [id, srcDoc] of srcMap.entries()) {
                const tgtDoc = tgtMap.get(id);

                if (!tgtDoc) {
                    inserts.push(srcDoc);
                } else {
                    // If requires update
                    const srcUpdatedAt =
                        srcDoc.updatedAt instanceof Date
                            ? srcDoc.updatedAt
                            : new Date(srcDoc.updatedAt);
                    const tgtUpdatedAt =
                        tgtDoc.updatedAt instanceof Date
                            ? tgtDoc.updatedAt
                            : new Date(tgtDoc.updatedAt);

                    if (srcUpdatedAt > tgtUpdatedAt) {
                        updates.push(srcDoc);
                    }
                }
            }

            // --- Compute deletes
            for (const [id, tgtDoc] of tgtMap.entries()) {
                if (!srcMap.has(id)) {
                    deletes.push(tgtDoc._id);
                }
            }

            // --- Apply inserts
            if (inserts.length > 0) {
                await tgtCol.insertMany(inserts);
            }

            // --- Apply updates (bulkWrite)
            if (updates.length > 0) {
                const bulkOps = updates.map((doc) => {
                    const { _id, ...data } = doc;
                    return {
                        updateOne: {
                            filter: { _id },
                            update: { $set: data },
                        },
                    };
                });
                await tgtCol.bulkWrite(bulkOps, { ordered: false });
            }

            // --- Apply deletes
            if (deletes.length > 0) {
                await tgtCol.deleteMany({
                    _id: { $in: deletes },
                });
            }

            report.push({
                collection: name,
                inserted: inserts.length,
                updated: updates.length,
                deleted: deletes.length,
            });

            totalInserted += inserts.length;
            totalUpdated += updates.length;
            totalDeleted += deletes.length;
        }

        await srcClient.close();
        await tgtClient.close();

        return {
            mode: "full-sync-diff",
            sourceDb: dbName,
            snapshotDb: snapshotDbName,
            inserted: totalInserted,
            updated: totalUpdated,
            deleted: totalDeleted,
            report,
        };
    }

    static async fullSyncManyDatabases(params: {
        targetURI: string;
        dbs: string[];
        keepWeeks?: number;
    }) {
        const { targetURI, dbs } = params;
        const keepWeeks = params.keepWeeks ?? 26;

        const sourceUri = process.env.MONGODB_URI!;

        const tasks = dbs.map((dbName) =>
            ClientDB.fullSyncOneDatabase({
                sourceUri,
                targetUri: targetURI,
                dbName,
            }),
        );

        // ⭐ Parallel execution
        const results = await Promise.allSettled(tasks);

        // Run cleanup AFTER all DBs synced
        await ClientDB.cleanupSnapshots({
            targetURI,
            dbs,
            keepWeeks,
        });

        return {
            mode: "full-sync-diff",
            syncedDatabases: dbs,
            results,
        };
    }

    static async cleanupSnapshots(params: {
        targetURI: string;
        dbs: string[];
        keepWeeks?: number;
    }) {
        const { targetURI, dbs } = params;
        const keepWeeks = params.keepWeeks ?? 26;

        const client = await ClientDB._connectExternal(targetURI);
        const admin = client.db().admin();

        const all = await admin.listDatabases();
        const names: string[] = all.databases.map(
            (d: { name: string }) => d.name,
        );

        const cutoff = subWeeks(new Date(), keepWeeks);
        const cutoffWeek = getISOWeek(cutoff);
        const cutoffYear = getISOWeekYear(cutoff);

        for (const base of dbs) {
            const prefix = `${base}-week-`;

            const matches = names.filter((name) => name.startsWith(prefix));

            for (const dbName of matches) {
                const parts = dbName.replace(prefix, "").split("-");
                const week = Number(parts[0]);
                const year = Number(parts[1]);

                const isOld =
                    year < cutoffYear ||
                    (year === cutoffYear && week < cutoffWeek);

                if (isOld) {
                    console.log(`🗑 Removing old snapshot: ${dbName}`);
                    await client.db(dbName).dropDatabase();
                }
            }
        }

        await client.close();
    }
}

export default ClientDB;
export { ObjectId };
