import { allPromises, allPromisesWithTimeLimit, BaseAdapter, LoggerPort, ConsumerRateLimitPort } from '@humand-packages/common';
import { Service } from 'typedi';
import { getConfig } from '../../../../../apiConfig/configSetup';
import { PortsDI } from '../../../../../config/portsDI';
import { FaceIndexResult } from '../../../../facialRecognition/business/interfaces/faceIndexResult';
import { FaceRecognitionConfiguration } from '../../../../facialRecognition/business/interfaces/faceRecognitionConfiguration';
import FaceRecognitionServicePort from '../../../../facialRecognition/business/ports/faceRecognitionPort';
import { SiteService } from '../../../../regions/business/services/sitesService';
import { OrphanedFaceAssignment } from '../../../business/interfaces/orphanedFaceAssignment';
import { TimeTrackingFaceAssignmentSource } from '../../../infrastructure/constants/timeTracking/faceAssignmentSource';
import TimeTrackingFaceAssignmentRepositoryPort from '../../../infrastructure/ports/timeTracking/timeTrackingFaceAssignmentRepositoryPort';

// Rate-limit bucket shared by all AWS Rekognition write operations (IndexFaces +
// DeleteFaces + DeleteCollection). One bucket because they share the per-region
// 50 TPS quota. Every AWS write goes through this bucket (see processOneIndex /
// processOneDelete / deleteSitesCollections / enrollInstanceCollection /
// unenrollInstanceCollection / compensateOrphan*).
const REKOGNITION_WRITE_BUCKET_KEY = 'rate-limit:rekognition:write-ops';

// Bounded parallelism within a batch. The token bucket is the real throttle —
// concurrency just allows several workers to race for tokens without burning
// idle time waiting between calls.
const BATCH_PARALLELISM = 5;

// allPromisesWithTimeLimit's 3rd arg is the PQueue dispatch window in seconds, NOT a
// per-task timeout: it starts at most BATCH_PARALLELISM tasks per this window. Tasks
// run to completion; nothing is aborted. (Real Rekognition throttling is the token bucket.)
const BATCH_DISPATCH_WINDOW_SECONDS = 1;

type PendingIndexWorkItem = {
    userId: number;
    siteId: number;
    regionId: number;
    collectionId: string;
    referenceImage: string;
};

type PendingDeleteWorkItem = {
    siteId: number;
    collectionId: string;
    faceIds: string[];
    assignmentIds: number[];
};

@Service()
export default class TimeTrackingFaceAssignmentService extends BaseAdapter {
    constructor(
        @PortsDI(TimeTrackingFaceAssignmentRepositoryPort)
        private readonly repo: TimeTrackingFaceAssignmentRepositoryPort,
        @PortsDI(FaceRecognitionServicePort) private readonly faceRecognition: FaceRecognitionServicePort,
        private readonly consumerRateLimit: ConsumerRateLimitPort,
        private readonly siteService: SiteService,
        private readonly logger: LoggerPort,
    ) {
        super();
    }

    public getSiteCollectionId = (instanceId: number, siteId: number): string => `${instanceId}-${siteId}`;
    public getInstanceCollectionId = (instanceId: number): string => `${instanceId}`;

    public handleUserCreation = async (instanceId: number, userId: number): Promise<void> => {
        await this.enrollInstanceCollection(instanceId, [userId]);
        await this.enrollSiteCollections(instanceId, [userId]);
    };

    public handleUserDeletion = async (instanceId: number, userId: number): Promise<void> => {
        await this.unenrollInstanceCollection(instanceId, [userId]);
        await this.unenrollSiteCollections(instanceId, [userId], {});
    };

    public handleUpdateUserFaces = async (instanceId: number, userIds: number[]): Promise<void> => {
        if (userIds.length === 0) return;
        await this.deleteStaleSiteAssignments(instanceId, userIds);
        await this.enrollSiteCollections(instanceId, userIds);
    };

    public handleUserAssignmentToSites = async (instanceId: number, userIds: number[]): Promise<void> => {
        if (userIds.length === 0) return;
        await this.deleteOrphanedSiteAssignments(instanceId, userIds);
        await this.enrollSiteCollections(instanceId, userIds);
    };

    public handleUserUnassignmentFromSite = async (instanceId: number, siteId: number, userIds: number[]): Promise<void> => {
        if (userIds.length === 0) return;
        await this.unenrollSiteCollections(instanceId, userIds, { siteId });
    };

    public handleUserUnassignmentFromRegion = async (instanceId: number, regionId: number, userIds: number[]): Promise<void> => {
        if (userIds.length === 0) return;
        await this.unenrollSiteCollections(instanceId, userIds, { regionId });
    };

    // User reset their reference image (FACE_CONFIG_DELETED). Drop every
    // per-site assignment. Instance-collection nullification already happened
    // in FaceRecognitionServiceAdapter.resetReferenceImage.
    public handleUserFaceConfigDeleted = async (instanceId: number, userIds: number[]): Promise<void> => {
        if (userIds.length === 0) return;
        await this.unenrollSiteCollections(instanceId, userIds, {});
    };

    // Whole-collection delete (SITE_DELETED / REGION_DELETED).
    public deleteSitesCollections = async (instanceId: number, siteIds: number[]): Promise<void> => {
        if (siteIds.length === 0) return;
        for (const siteId of siteIds) {
            const collectionId = this.getSiteCollectionId(instanceId, siteId);
            await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
            try {
                await this.faceRecognition.deleteCollection(collectionId);
            } catch (err) {
                this.logger.error({ collectionId, err, instanceId, siteId }, '[face-assignment] deleteCollection failed; will retry via DLQ');
                throw err;
            }
            // DB cleanup runs after the AWS delete on purpose. If deleteByIds below
            // fails, the collection is already gone but the rows remain; on retry
            // deleteCollection hits ResourceNotFoundException (swallowed by the
            // adapter's IGNORE_NOT_FOUND_ERROR_CODES) and we reach this cleanup again.
            const assignments = await this.repo.getAllBySiteId(instanceId, siteId, TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION);
            if (assignments.length > 0) {
                await this.repo.deleteByIds(
                    instanceId,
                    assignments.map((a) => a.id),
                );
            }
        }
    };

    public reconcilePendingAssignments = async (options?: {
        limit?: number;
        maxInstances?: number;
    }): Promise<{ pendingFound: number; usersProcessed: number; usersFailed: number }> => {
        const pending = await this.repo.findPendingAssignments(options);
        let usersProcessed = 0;
        let usersFailed = 0;

        this.logger.info(
            { limit: options?.limit, maxInstances: options?.maxInstances, pendingFound: pending.length },
            '[face-assignment] reconcile start',
        );

        const userIdsByInstance = new Map<number, Set<number>>();
        for (const item of pending) {
            if (!userIdsByInstance.has(item.instanceId)) userIdsByInstance.set(item.instanceId, new Set());
            userIdsByInstance.get(item.instanceId)!.add(item.userId);
        }

        for (const [instanceId, userIdsSet] of userIdsByInstance) {
            const userIds = [...userIdsSet];
            try {
                if (userIds.length > 0) {
                    await this.enrollSiteCollections(instanceId, userIds);
                }
                usersProcessed += userIds.length;
            } catch (error) {
                usersFailed += userIds.length;
                this.logger.error(
                    {
                        error: error instanceof Error ? { message: error.message, name: error.name } : { message: String(error) },
                        instanceId,
                        userCount: userIds.length,
                    },
                    '[face-assignment] reconcile batch failed',
                );
            }
        }

        this.logger.info({ pendingFound: pending.length, usersFailed, usersProcessed }, '[face-assignment] reconcile done');
        return { pendingFound: pending.length, usersFailed, usersProcessed };
    };

    // Reverse-reconcile: delete every stale assignment findOrphanedAssignments flags (membership gone,
    // or reference image gone/changed). Event-independent safety net, run by the reconcile cron.
    public reconcileOrphanedAssignments = async (options?: {
        limit?: number;
        maxInstances?: number;
    }): Promise<{ orphansFound: number; orphansDeleted: number; orphansFailed: number }> => {
        const orphaned = await this.repo.findOrphanedAssignments(options);
        this.logger.info(
            { limit: options?.limit, maxInstances: options?.maxInstances, orphansFound: orphaned.length },
            '[face-assignment] reverse-reconcile start',
        );
        if (orphaned.length === 0) return { orphansDeleted: 0, orphansFailed: 0, orphansFound: 0 };

        const itemsByInstance = new Map<number, OrphanedFaceAssignment[]>();
        for (const orphan of orphaned) {
            if (!itemsByInstance.has(orphan.instanceId)) itemsByInstance.set(orphan.instanceId, []);
            itemsByInstance.get(orphan.instanceId)!.push(orphan);
        }

        let orphansDeleted = 0;
        let orphansFailed = 0;
        for (const [instanceId, orphansForInstance] of itemsByInstance) {
            // Group per site so one deleteFaces call clears the whole site collection at once.
            const workBySite = new Map<number, PendingDeleteWorkItem>();
            for (const orphan of orphansForInstance) {
                const existing = workBySite.get(orphan.siteId);
                if (existing) {
                    existing.faceIds.push(orphan.faceId);
                    existing.assignmentIds.push(orphan.assignmentId);
                } else {
                    workBySite.set(orphan.siteId, {
                        assignmentIds: [orphan.assignmentId],
                        collectionId: this.getSiteCollectionId(instanceId, orphan.siteId),
                        faceIds: [orphan.faceId],
                        siteId: orphan.siteId,
                    });
                }
            }
            const itemResults = await allPromisesWithTimeLimit(
                [...workBySite.values()].map((item) => async () => {
                    try {
                        await this.processDeleteWorkItem(instanceId, item);
                        return { deleted: item.assignmentIds.length, failed: 0 };
                    } catch (error) {
                        this.logger.error(
                            {
                                error: error instanceof Error ? { message: error.message, name: error.name } : { message: String(error) },
                                instanceId,
                                siteId: item.siteId,
                            },
                            '[face-assignment] reverse-reconcile delete failed',
                        );
                        return { deleted: 0, failed: item.assignmentIds.length };
                    }
                }),
                BATCH_PARALLELISM,
                BATCH_DISPATCH_WINDOW_SECONDS,
            );
            for (const result of itemResults) {
                orphansDeleted += result.deleted;
                orphansFailed += result.failed;
            }
        }

        this.logger.info({ orphansDeleted, orphansFailed, orphansFound: orphaned.length }, '[face-assignment] reverse-reconcile done');
        return { orphansDeleted, orphansFailed, orphansFound: orphaned.length };
    };

    public bulkIndexFaces = async (
        instanceIds: number[],
        pagination?: { limit?: number; offset?: number },
    ): Promise<{ totalConfigurations: number; processedConfigurations: number }> => {
        type ConfigWithInstance = (FaceRecognitionConfiguration & { referenceImage: string }) & { instanceId: number };
        const allValid: ConfigWithInstance[] = [];

        for (const instanceId of instanceIds) {
            const configurations = await this.faceRecognition.getConfigurationsByInstanceId(instanceId);
            const seen = new Set<number>();
            const valid = configurations.filter((c): c is FaceRecognitionConfiguration & { referenceImage: string } => {
                if (!c.referenceImage || seen.has(c.userId)) return false;
                seen.add(c.userId);
                return true;
            });
            allValid.push(...valid.map((c) => ({ ...c, instanceId })));
        }

        const totalConfigurations = allValid.length;
        const offset = pagination?.offset ?? 0;
        const limit = pagination?.limit ?? totalConfigurations;
        const slice = allValid.slice(offset, offset + limit);

        const userIdsByInstance = new Map<number, number[]>();
        for (const c of slice) {
            const ids = userIdsByInstance.get(c.instanceId) ?? [];
            ids.push(c.userId);
            userIdsByInstance.set(c.instanceId, ids);
        }

        for (const [instanceId, userIds] of userIdsByInstance) {
            await this.enrollSiteCollections(instanceId, userIds);
        }

        return { processedConfigurations: slice.length, totalConfigurations };
    };

    private enrollInstanceCollection = async (instanceId: number, userIds: number[]): Promise<void> => {
        const configurations = await this.faceRecognition.getConfigurations(instanceId, [...new Set(userIds)]);
        for (const config of configurations) {
            if (!config.referenceImage) continue;
            if (config.faceId) continue; // already enrolled — idempotent
            await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
            const result = await this.faceRecognition.indexFace(
                this.getInstanceCollectionId(instanceId),
                String(config.userId),
                config.referenceImage,
            );
            if (!result.success) {
                this.logger.warn(
                    { error: result.error, instanceId, userId: config.userId },
                    '[face-assignment] instance-collection indexFace soft fail',
                );
                continue;
            }
            try {
                await this.faceRecognition.setFaceId(instanceId, config.userId, result.faceId);
            } catch (persistErr) {
                await this.compensateOrphanInstanceFace(instanceId, config.userId, result.faceId, persistErr);
                throw persistErr;
            }
        }
    };

    private unenrollInstanceCollection = async (instanceId: number, userIds: number[]): Promise<void> => {
        const configurations = await this.faceRecognition.getConfigurations(instanceId, [...new Set(userIds)]);
        for (const config of configurations) {
            if (!config.faceId) continue; // nothing to unenroll — idempotent
            await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
            // Safe to retry if removeFaceId throws after deleteFaces succeeds: AWS
            // DeleteFaces does not error on already-deleted face IDs (it returns them
            // under UnsuccessfulFaceDeletions), and a missing collection surfaces as
            // ResourceNotFoundException, swallowed by the adapter's IGNORE_NOT_FOUND_ERROR_CODES.
            await this.faceRecognition.deleteFaces(this.getInstanceCollectionId(instanceId), [config.faceId]);
            await this.faceRecognition.removeFaceId(instanceId, config.userId);
        }
    };

    private enrollSiteCollections = async (instanceId: number, userIds: number[]): Promise<void> => {
        const work = await this.computePendingIndexWork(instanceId, userIds);
        if (work.length === 0) return;
        this.logger.info({ instanceId, itemCount: work.length, userCount: userIds.length }, '[face-assignment] enrollSiteCollections start');
        await allPromisesWithTimeLimit(
            work.map((item) => () => this.processIndexWorkItem(instanceId, item)),
            BATCH_PARALLELISM,
            BATCH_DISPATCH_WINDOW_SECONDS,
        );
    };

    private unenrollSiteCollections = async (instanceId: number, userIds: number[], scope: { siteId?: number; regionId?: number }): Promise<void> => {
        const work = await this.computePendingDeleteWork(instanceId, userIds, scope);
        if (work.length === 0) return;
        this.logger.info({ instanceId, itemCount: work.length, scope, userCount: userIds.length }, '[face-assignment] unenrollSiteCollections start');
        await allPromisesWithTimeLimit(
            work.map((item) => () => this.processDeleteWorkItem(instanceId, item)),
            BATCH_PARALLELISM,
            BATCH_DISPATCH_WINDOW_SECONDS,
        );
    };

    private deleteStaleSiteAssignments = async (instanceId: number, userIds: number[]): Promise<void> => {
        const work = await this.computeStaleSiteAssignmentsWork(instanceId, userIds);
        if (work.length === 0) return;
        this.logger.info({ instanceId, itemCount: work.length, userCount: userIds.length }, '[face-assignment] deleteStaleSiteAssignments start');
        await allPromisesWithTimeLimit(
            work.map((item) => () => this.processDeleteWorkItem(instanceId, item)),
            BATCH_PARALLELISM,
            BATCH_DISPATCH_WINDOW_SECONDS,
        );
    };

    private deleteOrphanedSiteAssignments = async (instanceId: number, userIds: number[]): Promise<void> => {
        const work = await this.computeOrphanedSiteAssignmentsWork(instanceId, userIds);
        if (work.length === 0) return;
        this.logger.info({ instanceId, itemCount: work.length, userCount: userIds.length }, '[face-assignment] deleteOrphanedSiteAssignments start');
        await allPromisesWithTimeLimit(
            work.map((item) => () => this.processDeleteWorkItem(instanceId, item)),
            BATCH_PARALLELISM,
            BATCH_DISPATCH_WINDOW_SECONDS,
        );
    };

    // Assignments whose site is no longer in the user's current SiteUserRegion membership
    // (e.g. after a region move silently drops the old SiteUserRegion). Computed per user in
    // parallel (bounded concurrency); each user's items are grouped per site so
    // processDeleteWorkItem deletes the AWS faces and DB rows together.
    private computeOrphanedSiteAssignmentsWork = async (instanceId: number, userIds: number[]): Promise<PendingDeleteWorkItem[]> => {
        const uniqueUserIds = [...new Set(userIds)];
        const workPerUser = await allPromises(uniqueUserIds.map((userId) => () => this.computeOrphanedSiteAssignmentsForUser(instanceId, userId)));
        return workPerUser.flat();
    };

    private computeOrphanedSiteAssignmentsForUser = async (instanceId: number, userId: number): Promise<PendingDeleteWorkItem[]> => {
        const sites = await this.siteService.getByUserId(userId, instanceId);
        const currentSiteIds = new Set(sites.map((site) => site.getId()));
        const assignments = await this.repo.getAll(instanceId, userId, { source: TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION });
        const itemsBySite = new Map<number, PendingDeleteWorkItem>();
        for (const assignment of assignments) {
            if (currentSiteIds.has(assignment.siteId)) continue; // still a member — keep
            const existing = itemsBySite.get(assignment.siteId);
            if (existing) {
                existing.faceIds.push(assignment.faceId);
                existing.assignmentIds.push(assignment.id);
            } else {
                itemsBySite.set(assignment.siteId, {
                    assignmentIds: [assignment.id],
                    collectionId: this.getSiteCollectionId(instanceId, assignment.siteId),
                    faceIds: [assignment.faceId],
                    siteId: assignment.siteId,
                });
            }
        }
        return [...itemsBySite.values()];
    };

    private computeStaleSiteAssignmentsWork = async (instanceId: number, userIds: number[]): Promise<PendingDeleteWorkItem[]> => {
        const uniqueUserIds = [...new Set(userIds)];
        const configurations = await this.faceRecognition.getConfigurations(instanceId, uniqueUserIds);
        const configByUserId = new Map<number, FaceRecognitionConfiguration>();
        for (const config of configurations) configByUserId.set(config.userId, config);

        const itemsBySiteForUser = new Map<string, PendingDeleteWorkItem>();
        for (const userId of uniqueUserIds) {
            const config = configByUserId.get(userId);
            if (!config?.referenceImage) continue;
            const assignments = await this.repo.getAll(instanceId, userId, { source: TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION });
            for (const a of assignments) {
                if (a.referenceImage === config.referenceImage) continue; // already converged — idempotent
                const key = `${userId}:${a.siteId}`;
                const existing = itemsBySiteForUser.get(key);
                if (existing) {
                    existing.faceIds.push(a.faceId);
                    existing.assignmentIds.push(a.id);
                } else {
                    itemsBySiteForUser.set(key, {
                        assignmentIds: [a.id],
                        collectionId: this.getSiteCollectionId(instanceId, a.siteId),
                        faceIds: [a.faceId],
                        siteId: a.siteId,
                    });
                }
            }
        }
        return [...itemsBySiteForUser.values()];
    };

    private computePendingIndexWork = async (instanceId: number, userIds: number[]): Promise<PendingIndexWorkItem[]> => {
        const uniqueUserIds = [...new Set(userIds)];
        const configurations = await this.faceRecognition.getConfigurations(instanceId, uniqueUserIds);
        const configByUserId = new Map<number, FaceRecognitionConfiguration>();
        for (const config of configurations) configByUserId.set(config.userId, config);

        const work: PendingIndexWorkItem[] = [];
        for (const userId of uniqueUserIds) {
            const config = configByUserId.get(userId);
            if (!config?.referenceImage) continue;
            const currentAssignments = await this.repo.getAll(instanceId, userId, { source: TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION });
            const assignedSiteIds = new Set(currentAssignments.map((a) => a.siteId));
            const sites = await this.siteService.getByUserId(userId, instanceId);
            for (const site of sites) {
                if (assignedSiteIds.has(site.getId())) continue;
                work.push({
                    collectionId: this.getSiteCollectionId(instanceId, site.getId()),
                    referenceImage: config.referenceImage,
                    regionId: site.getRegionId(),
                    siteId: site.getId(),
                    userId,
                });
            }
        }
        return work;
    };

    private computePendingDeleteWork = async (
        instanceId: number,
        userIds: number[],
        scope: { siteId?: number; regionId?: number },
    ): Promise<PendingDeleteWorkItem[]> => {
        const uniqueUserIds = [...new Set(userIds)];
        const itemsBySiteForUser = new Map<string, PendingDeleteWorkItem>();
        for (const userId of uniqueUserIds) {
            const assignments = await this.repo.getAll(instanceId, userId, {
                source: TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION,
                ...scope,
            });
            for (const assignment of assignments) {
                const key = `${userId}:${assignment.siteId}`;
                const existing = itemsBySiteForUser.get(key);
                if (existing) {
                    existing.faceIds.push(assignment.faceId);
                    existing.assignmentIds.push(assignment.id);
                } else {
                    itemsBySiteForUser.set(key, {
                        assignmentIds: [assignment.id],
                        collectionId: this.getSiteCollectionId(instanceId, assignment.siteId),
                        faceIds: [assignment.faceId],
                        siteId: assignment.siteId,
                    });
                }
            }
        }
        return [...itemsBySiteForUser.values()];
    };

    private processIndexWorkItem = async (instanceId: number, item: PendingIndexWorkItem): Promise<void> => {
        await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
        let result: FaceIndexResult;
        try {
            result = await this.faceRecognition.indexFace(item.collectionId, String(item.userId), item.referenceImage);
        } catch (err) {
            this.logger.error({ collectionId: item.collectionId, err, userId: item.userId }, '[face-assignment] indexFace threw; will retry via DLQ');
            throw err;
        }
        if (!result.success) {
            this.logger.warn({ collectionId: item.collectionId, error: result.error, userId: item.userId }, '[face-assignment] indexFace soft fail');
            return;
        }
        try {
            await this.repo.bulkCreate([
                {
                    faceId: result.faceId,
                    instanceId,
                    referenceImage: item.referenceImage,
                    regionId: item.regionId,
                    siteId: item.siteId,
                    source: TimeTrackingFaceAssignmentSource.FACIAL_RECOGNITION,
                    userId: item.userId,
                },
            ]);
        } catch (persistErr) {
            await this.compensateOrphanSiteFace(item.collectionId, result.faceId, item.userId, persistErr);
            throw persistErr;
        }
    };

    private processDeleteWorkItem = async (instanceId: number, item: PendingDeleteWorkItem): Promise<void> => {
        await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
        try {
            await this.faceRecognition.deleteFaces(item.collectionId, item.faceIds);
        } catch (err) {
            this.logger.error(
                { assignmentIds: item.assignmentIds, collectionId: item.collectionId, err },
                '[face-assignment] deleteFaces threw; will retry via DLQ',
            );
            throw err;
        }
        await this.repo.deleteByIds(instanceId, item.assignmentIds);
    };

    private compensateOrphanSiteFace = async (collectionId: string, faceId: string, userId: number, originalErr: unknown): Promise<void> => {
        try {
            await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
            await this.faceRecognition.deleteFaces(collectionId, [faceId]);
            this.logger.warn({ collectionId, faceId, originalErr, userId }, '[face-assignment] persist failed; orphan site face deleted from AWS');
        } catch (compErr) {
            this.logger.error(
                { collectionId, compErr, faceId, originalErr, userId },
                '[face-assignment] ORPHAN: persist + compensating delete both failed (site collection)',
            );
        }
    };

    private compensateOrphanInstanceFace = async (instanceId: number, userId: number, faceId: string, originalErr: unknown): Promise<void> => {
        try {
            await this.consumerRateLimit.waitAndConsume(REKOGNITION_WRITE_BUCKET_KEY, this.tokenBucketBurst(), this.tokenBucketRefill(), 1);
            await this.faceRecognition.deleteFaces(this.getInstanceCollectionId(instanceId), [faceId]);
            this.logger.warn({ faceId, instanceId, originalErr, userId }, '[face-assignment] persist failed; orphan instance face deleted from AWS');
        } catch (compErr) {
            this.logger.error(
                { compErr, faceId, instanceId, originalErr, userId },
                '[face-assignment] ORPHAN: persist + compensating delete both failed (instance collection)',
            );
        }
    };

    private tokenBucketBurst = (): number => getConfig().rekognitionTokenBucketBurst!;
    private tokenBucketRefill = (): number => getConfig().rekognitionTokenBucketRefillPerSecond!;
}
