import {
    allPromises,
    BadRequestError,
    BaseAdapter,
    ForbiddenError,
    ErrorCodes,
    NotFoundError,
    RequestConflictError,
    ISOLATION_LEVELS,
} from '@humand-packages/common';
import { Service } from 'typedi';
import { PortsDI } from '../../../../../config/portsDI';
import { AttachmentsService } from '../../../../attachments/business/services/attachmentsService';
import { Instance } from '../../../../instances/business/models/instance';
import { OrganizationChartsService } from '../../../../organizationCharts/business/services/organizationChartsService';
import { SegmentationsService } from '../../../../segmentations/business/services/segmentationsService';
import { User } from '../../../../users/business/models/user';
import { UsersService } from '../../../../users/business/services/usersService';
import { userDAOtoUser } from '../../../../users/infrastructure/mappers/usersMapper';
import { TimeTrackingCategorizedHourApprovalStepState } from '../../../infrastructure/constants/timeTracking/categorizedHourApprovalStepStates';
import { TimeTrackingCategorizedHourStatus } from '../../../infrastructure/constants/timeTracking/categorizedHourStatus';
import { TimeTrackingCategorizedHourApprovalStepRepositoryPort } from '../../../infrastructure/ports/timeTracking/timeTrackingCategorizedHourApprovalStepRepositoryPort';
import { TimeTrackingCategorizedHourRepositoryPort } from '../../../infrastructure/ports/timeTracking/timeTrackingCategorizedHourRepositoryPort';
import { TimeTrackingHourCategorizationRepositoryPort } from '../../../infrastructure/ports/timeTracking/timeTrackingHourCategorizationRepositoryPort';
import { BULK_REVIEW_HARD_CAP } from '../../constants/bulkReview';
import { TimeTrackingPolicyApprovalStep } from '../../models/policies/approvalSteps/timeTrackingPolicyApprovalStep';
import { BulkReviewCategorizedHoursResult } from '../../models/timeTracking/bulkReviewCategorizedHoursResult';
import { CategorizedHourStatusOverride } from '../../models/timeTracking/categorizedHourStatusOverride';
import { EditCategorizedHourStatusResult } from '../../models/timeTracking/editCategorizedHourStatusResult';
import { SavedPendingCategorizedHour } from '../../models/timeTracking/timeTrackingCategorizedHour';
import {
    TimeTrackingCategorizedHourApprovalStep,
    TimeTrackingCategorizedHourApprovalStepForCreation,
} from '../../models/timeTracking/timeTrackingCategorizedHourApprovalStep';
import { TimeTrackingCategorizedHourApprovalStepPotentialApproverForCreation } from '../../models/timeTracking/timeTrackingCategorizedHourApprovalStepPotentialApprover';
import { TimeTrackingPoliciesPort } from '../../ports/timeTrackingPoliciesPort';
import { UsersServicePort } from '../../ports/userServicePort';
import { BulkReviewCategorizedHoursParams } from '../../types/hourCategorization/bulkReviewCategorizedHoursParams';

export interface ReviewCategorizedHourApprovalStepParams {
    state: TimeTrackingCategorizedHourApprovalStepState.APPROVED | TimeTrackingCategorizedHourApprovalStepState.REJECTED;
    comment?: string | null;
}

@Service()
export class TimeTrackingCategorizedHourApprovalService extends BaseAdapter {
    constructor(
        @PortsDI(TimeTrackingCategorizedHourApprovalStepRepositoryPort)
        private readonly approvalStepRepository: TimeTrackingCategorizedHourApprovalStepRepositoryPort,
        @PortsDI(TimeTrackingCategorizedHourRepositoryPort)
        private readonly categorizedHourRepository: TimeTrackingCategorizedHourRepositoryPort,
        @PortsDI(TimeTrackingPoliciesPort)
        private readonly policiesService: TimeTrackingPoliciesPort,
        private readonly organizationChartsService: OrganizationChartsService,
        private readonly segmentationsService: SegmentationsService,
        private readonly usersService: UsersService,
        private readonly attachmentsService: AttachmentsService,
        @PortsDI(TimeTrackingHourCategorizationRepositoryPort)
        private readonly hourCategorizationRepository: TimeTrackingHourCategorizationRepositoryPort,
        @PortsDI(UsersServicePort)
        private readonly usersServicePort: UsersServicePort,
    ) {
        super();
    }

    /**
     * Creates a snapshot of the current approval flow for a newly created PENDING categorized hour.
     * Must be called once per categorized hour creation with PENDING status.
     */
    public createApprovalStepsSnapshot = async (
        categorizedHourId: string,
        userId: number,
        instanceId: number,
        policyId: number | null,
    ): Promise<TimeTrackingCategorizedHourApprovalStep | null> => {
        let policySteps = await this.policiesService.getApprovalSteps(instanceId, policyId);
        if (policySteps.length === 0 && policyId !== null) {
            policySteps = await this.policiesService.getApprovalSteps(instanceId, null);
        }
        if (policySteps.length === 0) return null;
        policySteps.sort((a, b) => a.position - b.position);

        let firstStep: TimeTrackingCategorizedHourApprovalStep | null = null;
        const firstPosition = policySteps[0].position;

        await this.approvalStepRepository.getTransaction(async () => {
            const createdSteps = await this.approvalStepRepository.bulkCreateCategorizedHourApprovalSteps(
                policySteps.map(
                    (policyStep, i) =>
                        new TimeTrackingCategorizedHourApprovalStepForCreation({
                            approverId: null,
                            categorizedHourId,
                            comment: null,
                            policyApprovalStepId: policyStep.id,
                            position: policyStep.position,
                            resolutionDate: null,
                            state:
                                i === 0
                                    ? TimeTrackingCategorizedHourApprovalStepState.PENDING
                                    : TimeTrackingCategorizedHourApprovalStepState.WAITING_OTHER_STEP,
                        }),
                ),
            );

            const stepByPosition = new Map(createdSteps.map((s) => [s.position, s]));
            firstStep = stepByPosition.get(firstPosition) ?? null;

            await allPromises(
                policySteps.map((policyStep) => async () => {
                    const requestStep = stepByPosition.get(policyStep.position);
                    if (!requestStep) return;
                    const approverIds = await this.resolveApproverIds(policyStep, userId, instanceId);
                    if (approverIds.length > 0) {
                        await this.approvalStepRepository.bulkCreateCategorizedHourApprovalStepPotentialApprovers(
                            approverIds.map(
                                (approverId) =>
                                    new TimeTrackingCategorizedHourApprovalStepPotentialApproverForCreation({
                                        approvalStepId: requestStep.id,
                                        categorizedHourId,
                                        userId: approverId,
                                    }),
                            ),
                        );
                    }
                }),
            );
        });

        return firstStep;
    };

    /**
     * Fetches policy steps once and bulk-creates all steps for all hours in a single insert,
     * then resolves approvers in parallel per (hour, step) combination.
     */
    public bulkCreateApprovalStepsSnapshots = async (
        hours: SavedPendingCategorizedHour[],
        instanceId: number,
        policyId: number | null,
    ): Promise<TimeTrackingCategorizedHourApprovalStep[]> => {
        if (hours.length === 0) return [];

        let policySteps = await this.policiesService.getApprovalSteps(instanceId, policyId);
        if (policySteps.length === 0 && policyId !== null) {
            policySteps = await this.policiesService.getApprovalSteps(instanceId, null);
        }
        if (policySteps.length === 0) return [];
        policySteps.sort((a, b) => a.position - b.position);
        const firstPosition = policySteps[0].position;

        const firstStepByHour = new Map<string, TimeTrackingCategorizedHourApprovalStep>();

        await this.approvalStepRepository.getTransaction(async () => {
            const createdSteps = await this.approvalStepRepository.bulkCreateCategorizedHourApprovalSteps(
                hours.flatMap((hour) =>
                    policySteps.map(
                        (policyStep, i) =>
                            new TimeTrackingCategorizedHourApprovalStepForCreation({
                                approverId: null,
                                categorizedHourId: hour.id,
                                comment: null,
                                policyApprovalStepId: policyStep.id,
                                position: policyStep.position,
                                resolutionDate: null,
                                state:
                                    i === 0
                                        ? TimeTrackingCategorizedHourApprovalStepState.PENDING
                                        : TimeTrackingCategorizedHourApprovalStepState.WAITING_OTHER_STEP,
                            }),
                    ),
                ),
            );

            const stepByHourAndPosition = new Map(createdSteps.map((s) => [`${s.categorizedHourId}:${s.position}`, s]));
            for (const hour of hours) {
                const firstStep = stepByHourAndPosition.get(`${hour.id}:${firstPosition}`);
                if (firstStep) firstStepByHour.set(hour.id, firstStep);
            }

            await allPromises(
                hours.flatMap((hour) =>
                    policySteps.map((policyStep) => async () => {
                        const requestStep = stepByHourAndPosition.get(`${hour.id}:${policyStep.position}`);
                        if (!requestStep) return;
                        const approverIds = await this.resolveApproverIds(policyStep, hour.userId, instanceId);
                        if (approverIds.length > 0) {
                            await this.approvalStepRepository.bulkCreateCategorizedHourApprovalStepPotentialApprovers(
                                approverIds.map(
                                    (approverId) =>
                                        new TimeTrackingCategorizedHourApprovalStepPotentialApproverForCreation({
                                            approvalStepId: requestStep.id,
                                            categorizedHourId: hour.id,
                                            userId: approverId,
                                        }),
                                ),
                            );
                        }
                    }),
                ),
            );
        });

        return Array.from(firstStepByHour.values());
    };

    /**
     * Approves or rejects a specific approval step for a categorized hour.
     * Admins (hasManageCapability=true) can review any step.
     * Non-admins must be a potential approver. They can also modify their own
     * previous response as long as no subsequent step has been reviewed yet.
     */
    public reviewCategorizedHourApprovalStep = async (
        loggedUserId: number,
        hasManageCapability: boolean,
        instanceId: number,
        categorizedHourId: string,
        stepId: string,
        params: ReviewCategorizedHourApprovalStepParams,
    ): Promise<TimeTrackingCategorizedHourApprovalStep> => {
        return this.approvalStepRepository.transactionWithAdvisoryLock(
            `review-categorized-hour-approval:${categorizedHourId}`,
            async () => {
                let step: TimeTrackingCategorizedHourApprovalStep | null;

                // The flow is frozen for everyone (admins included) only when an admin has REJECTED
                // (revoked) the hour. An admin can lift the freeze by re-overriding to APPROVED — the
                // "undo revoke" path — after which steps become editable again.
                const isFrozen = await this.categorizedHourRepository.isFrozenByAdminRejection(categorizedHourId, instanceId);
                if (isFrozen) {
                    throw new RequestConflictError('Cannot review steps after admin status override', ErrorCodes.ALREADY_REVIEWED_APPROVAL_STEP);
                }

                if (hasManageCapability) {
                    step = await this.approvalStepRepository.findCategorizedHourApprovalStep(stepId, categorizedHourId, instanceId);
                    if (step.state === TimeTrackingCategorizedHourApprovalStepState.WAITING_OTHER_STEP) {
                        throw new ForbiddenError('Cannot review a step that is not yet active', ErrorCodes.ACCESS_DENIED);
                    }
                } else {
                    step = await this.approvalStepRepository.findCategorizedHourApprovalStepForApprover(stepId, categorizedHourId, loggedUserId);
                    if (!step) {
                        throw new ForbiddenError('You are not allowed to review this step', ErrorCodes.ACCESS_DENIED);
                    }
                }

                // If modifying a previous review, rollback its effects first
                await this.rollbackPreviousReview(step, categorizedHourId, hasManageCapability, loggedUserId, instanceId);

                // When an admin reviews a step with no potential approvers they are "unblocking" it,
                // not acting as an approver — approverId should remain null per product spec.
                const effectiveApproverId = hasManageCapability && step.potentialApprovers.length === 0 ? null : loggedUserId;
                // Record the new decision on the step and persist it
                step.review(effectiveApproverId, params.state, params.comment ?? null);
                await this.approvalStepRepository.updateCategorizedHourApprovalStep(step);

                // reject the hour, activate the next step, or fully approve
                await this.advanceApprovalFlowAfterReview(step.id, categorizedHourId, params.state, instanceId);

                return step;
            },
            { exponentialBackoff: true, isolationLevel: ISOLATION_LEVELS.SERIALIZABLE, tries: 3 },
        );
    };

    /**
     * Bulk-reviews the currently-active approval step for many categorized hours in a single transaction.
     * Non-reviewable targets (not found, admin-overridden, no active step, caller not an approver,
     * or lost to concurrency) are collected as skipped entries instead of aborting the batch.
     */
    public bulkReviewCategorizedHours = async (
        instance: Instance,
        user: User,
        params: BulkReviewCategorizedHoursParams,
        hasManageCapability: boolean,
    ): Promise<BulkReviewCategorizedHoursResult> => {
        // 1) resolve targetHourIds (shape mutual-exclusion is enforced at the controller via validateBulkReviewShape)
        const excludeSet = new Set(params.excludeCategorizedHourIds ?? []);
        let targetHourIds: string[];
        if (params.allRows) {
            const searchedUserIds =
                params.filters!.search !== undefined
                    ? await this.usersServicePort.getUserIds(user, { search: params.filters!.search }, hasManageCapability)
                    : undefined;
            const all = await this.hourCategorizationRepository.listCategorizedHourIdsByFilters({
                instanceId: instance.getId(),
                filters: params.filters!,
                loggedUserId: user.getId(),
                hasManageCapability,
                searchedUserIds,
            });
            targetHourIds = all.filter((id) => !excludeSet.has(id));
        } else {
            targetHourIds = params.categorizedHourIds!;
        }
        if (targetHourIds.length > BULK_REVIEW_HARD_CAP) {
            throw new BadRequestError(`Bulk review exceeds cap (${BULK_REVIEW_HARD_CAP})`, ErrorCodes.TIME_TRACKING_BULK_REVIEW_TOO_MANY);
        }

        // 3) pre-flight classify
        const { toReview, skippedCount: classifySkippedCount } = await this.approvalStepRepository.classifyForBulkReview({
            instanceId: instance.getId(),
            hourIds: targetHourIds,
            loggedUserId: user.getId(),
        });

        if (toReview.length === 0) {
            return new BulkReviewCategorizedHoursResult({
                reviewedIds: [],
                skippedCount: classifySkippedCount,
                reviewedSteps: [],
            });
        }

        // 4) execute inside a single outer transaction: optimistic step UPDATE, then per-hour
        //    advancement reusing the same helper as the single-hour review path.
        const { reviewed, concurrencyLosers } = await this.approvalStepRepository.getTransaction(async () => {
            const result = await this.approvalStepRepository.executeBulkReview({
                stepIds: toReview.map((x) => x.stepId),
                approverId: user.getId(),
                state: params.state,
                comment: params.comment ?? null,
            });
            await allPromises(
                result.reviewed.map(
                    ({ stepId, hourId }) =>
                        () =>
                            this.advanceApprovalFlowAfterReview(stepId, hourId, params.state, instance.getId()),
                ),
            );
            return result;
        });

        return new BulkReviewCategorizedHoursResult({
            reviewedIds: reviewed.map((x) => x.hourId),
            skippedCount: classifySkippedCount + concurrencyLosers.length,
            reviewedSteps: reviewed.map(({ stepId, hourId }) => ({ categorizedHourId: hourId, approvalStepId: stepId })),
        });
    };

    /**
     * After a step is reviewed, propagates the decision to the approval flow:
     * - Rejection → immediately rejects the categorized hour.
     * - Approval with remaining steps → activates the next waiting step.
     * - Approval on last step → marks the categorized hour as fully approved.
     */
    private advanceApprovalFlowAfterReview = async (
        reviewedStepId: string,
        categorizedHourId: string,
        reviewOutcome: TimeTrackingCategorizedHourApprovalStepState,
        instanceId: number,
    ): Promise<void> => {
        // Any single rejection is terminal: the entire categorized hour is rejected regardless of remaining steps
        if (reviewOutcome === TimeTrackingCategorizedHourApprovalStepState.REJECTED) {
            await this.categorizedHourRepository.updateCategorizedHourStatus(
                categorizedHourId,
                instanceId,
                TimeTrackingCategorizedHourStatus.REJECTED,
            );
            return;
        }

        const allSteps = await this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId);
        const nextWaitingStep = allSteps
            .filter((s) => s.id !== reviewedStepId && s.state === TimeTrackingCategorizedHourApprovalStepState.WAITING_OTHER_STEP)
            .sort((a, b) => a.position - b.position)[0];

        if (nextWaitingStep) {
            // More steps remain: activate the next one and keep the hour pending
            nextWaitingStep.state = TimeTrackingCategorizedHourApprovalStepState.PENDING;
            await this.approvalStepRepository.updateCategorizedHourApprovalStep(nextWaitingStep);
            await this.categorizedHourRepository.updateCategorizedHourStatus(
                categorizedHourId,
                instanceId,
                TimeTrackingCategorizedHourStatus.PENDING,
            );
            return;
        }

        // No waiting steps remain — but a sibling may still be REJECTED (e.g. admin re-reviewed
        // an earlier step after a later one was rejected). Only promote to APPROVED when every
        // other step is confirmed APPROVED.
        const allOtherStepsApproved = allSteps
            .filter((s) => s.id !== reviewedStepId)
            .every((s) => s.state === TimeTrackingCategorizedHourApprovalStepState.APPROVED);
        if (allOtherStepsApproved) {
            await this.categorizedHourRepository.updateCategorizedHourStatus(
                categorizedHourId,
                instanceId,
                TimeTrackingCategorizedHourStatus.APPROVED,
            );
        }
        // else: at least one sibling is REJECTED — leave the hour status unchanged
    };

    /**
     * Rollbacks based on the reviewer's role:
     * - Admin: rollback side-effects unconditionally (no validation needed).
     * - Non-admin: validate ownership and flow state first, then rollback.
     */
    private rollbackPreviousReview = async (
        step: TimeTrackingCategorizedHourApprovalStep,
        categorizedHourId: string,
        hasManageCapability: boolean,
        loggedUserId: number,
        instanceId: number,
    ): Promise<void> => {
        if (hasManageCapability) {
            await this.rollbackPreviousApprovalEffect(step, categorizedHourId, instanceId);
        } else if (step.state !== TimeTrackingCategorizedHourApprovalStepState.PENDING) {
            await this.validateAndRollbackPreviousReview(step, loggedUserId, categorizedHourId, instanceId);
        }
    };

    /**
     * Rolls back the side-effects of a previous APPROVED decision on a step:
     * if the approval had auto-advanced the next waiting step to PENDING, that
     * step is reverted to WAITING_OTHER_STEP so the flow can be re-advanced cleanly.
     * No-op when the step was not previously APPROVED.
     */
    private rollbackPreviousApprovalEffect = async (
        step: TimeTrackingCategorizedHourApprovalStep,
        categorizedHourId: string,
        instanceId: number,
        preloadedSteps?: TimeTrackingCategorizedHourApprovalStep[],
    ): Promise<void> => {
        if (step.state !== TimeTrackingCategorizedHourApprovalStepState.APPROVED) return;

        const allSteps = preloadedSteps ?? (await this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId));
        const nextAutoAdvancedStep = allSteps
            .filter((s) => s.position > step.position && s.state === TimeTrackingCategorizedHourApprovalStepState.PENDING)
            .sort((a, b) => a.position - b.position)[0];

        if (nextAutoAdvancedStep) {
            nextAutoAdvancedStep.state = TimeTrackingCategorizedHourApprovalStepState.WAITING_OTHER_STEP;
            await this.approvalStepRepository.updateCategorizedHourApprovalStep(nextAutoAdvancedStep);
        }
    };

    /**
     * Guards a non-admin approver's attempt to change a previously submitted review.
     * Allowed only when:
     * 1. The step was reviewed by this same user (approverId matches).
     * 2. No subsequent step has been reviewed yet (the flow hasn't moved irreversibly).
     *
     * If the original review was an approval that auto-advanced the next step to PENDING,
     * that advancement is rolled back (next step reverts to WAITING_OTHER_STEP) so the
     * new review can re-advance the flow cleanly via advanceApprovalFlowAfterReview.
     */
    private validateAndRollbackPreviousReview = async (
        step: TimeTrackingCategorizedHourApprovalStep,
        loggedUserId: number,
        categorizedHourId: string,
        instanceId: number,
    ): Promise<void> => {
        const isReviewed =
            step.state === TimeTrackingCategorizedHourApprovalStepState.APPROVED ||
            step.state === TimeTrackingCategorizedHourApprovalStepState.REJECTED;

        if (!isReviewed || step.approverId !== loggedUserId) {
            throw new RequestConflictError('This step has already been reviewed', ErrorCodes.ALREADY_REVIEWED_APPROVAL_STEP);
        }

        const allSteps = await this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId);
        const hasSubsequentReviewedStep = allSteps.some(
            (s) =>
                s.position > step.position &&
                (s.state === TimeTrackingCategorizedHourApprovalStepState.APPROVED ||
                    s.state === TimeTrackingCategorizedHourApprovalStepState.REJECTED),
        );

        if (hasSubsequentReviewedStep) {
            throw new RequestConflictError('Cannot modify: subsequent steps have already been reviewed', ErrorCodes.ALREADY_REVIEWED_APPROVAL_STEP);
        }

        await this.rollbackPreviousApprovalEffect(step, categorizedHourId, instanceId, allSteps);
    };

    /**
     * Idempotent variant: creates approval steps only if none exist yet for the given categorized hour.
     * Safe to call on upserted rows where steps may already be present.
     */
    public createApprovalStepsSnapshotIfNeeded = async (
        categorizedHourId: string,
        userId: number,
        instanceId: number,
        policyId: number | null,
    ): Promise<TimeTrackingCategorizedHourApprovalStep | null> => {
        const existingSteps = await this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId);
        if (existingSteps.length > 0) return null;
        return await this.createApprovalStepsSnapshot(categorizedHourId, userId, instanceId, policyId);
    };

    public getCategorizedHourApprovalSteps = async (
        instanceId: number,
        categorizedHourId: string,
    ): Promise<TimeTrackingCategorizedHourApprovalStep[]> => {
        return this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId);
    };

    /**
     * Admin-only override of the categorized hour status. Two semantic roles map onto this endpoint:
     *  - REJECTED → revoke a fully-approved hour. Allowed only when every step is APPROVED. Freezes
     *    the approval flow (see reviewCategorizedHourApprovalStep).
     *  - APPROVED → undo a previous revocation, restoring the hour to its approved state. The admin
     *    and comment are preserved on `statusOverriddenByAdminId` / `statusOverrideComment` as the
     *    audit trail of the undo. Lifts the freeze so admins can re-edit steps afterwards.
     *  - PENDING → not allowed (the UI does not expose it and an override is meant to be terminal).
     *
     * Steps and their approverId values are left untouched — they remain as historical record.
     */
    public editCategorizedHourStatus = async (
        instanceId: number,
        categorizedHourId: string,
        status: TimeTrackingCategorizedHourStatus,
        override: CategorizedHourStatusOverride,
    ): Promise<EditCategorizedHourStatusResult> => {
        await this.approvalStepRepository.transactionWithAdvisoryLock(
            `review-categorized-hour-approval:${categorizedHourId}`,
            async () => {
                const exists = await this.categorizedHourRepository.existsCategorizedHour(categorizedHourId, instanceId);
                if (!exists) {
                    throw new NotFoundError('Categorized hour not found', ErrorCodes.TIME_TRACKING_CATEGORIZED_HOUR_NOT_FOUND);
                }
                if (status === TimeTrackingCategorizedHourStatus.PENDING) {
                    throw new RequestConflictError('Cannot override status to PENDING', ErrorCodes.ALREADY_REVIEWED_APPROVAL_STEP);
                }
                if (status === TimeTrackingCategorizedHourStatus.REJECTED) {
                    const allSteps = await this.approvalStepRepository.listCategorizedHourApprovalSteps(categorizedHourId, instanceId);
                    // Guard against vacuous-truth: an hour with no steps (e.g. AUTO_APPROVED) has
                    // nothing to approve, so there is nothing to "veto" via rejection either.
                    const allApproved =
                        allSteps.length > 0 && allSteps.every((s) => s.state === TimeTrackingCategorizedHourApprovalStepState.APPROVED);
                    if (!allApproved) {
                        throw new RequestConflictError(
                            'Cannot override status to REJECTED unless every step is APPROVED',
                            ErrorCodes.ALREADY_REVIEWED_APPROVAL_STEP,
                        );
                    }
                }
                await this.categorizedHourRepository.updateCategorizedHourStatus(categorizedHourId, instanceId, status, override);
            },
            { exponentialBackoff: true, isolationLevel: ISOLATION_LEVELS.SERIALIZABLE, tries: 3 },
        );

        const [approvalSteps, adminDAOs] = await Promise.all([
            this.getEnrichedCategorizedHourApprovalSteps(instanceId, categorizedHourId),
            this.usersService.findByPks([override.adminId], { instanceId }),
        ]);
        let admin: User | null = null;
        if (adminDAOs.length > 0) {
            await this.attachmentsService.addSignedUrlToModelWithImages(instanceId, adminDAOs, ['profilePicture']);
            admin = userDAOtoUser(adminDAOs[0]);
        }
        return new EditCategorizedHourStatusResult({ admin, approvalSteps });
    };

    public getEnrichedCategorizedHourApprovalSteps = async (
        instanceId: number,
        categorizedHourId: string,
    ): Promise<TimeTrackingCategorizedHourApprovalStep[]> => {
        const approvalSteps = await this.getCategorizedHourApprovalSteps(instanceId, categorizedHourId);
        if (approvalSteps.length === 0) return approvalSteps;

        const userIds = approvalSteps.flatMap((step) => {
            const ids = step.potentialApprovers.map((pa) => pa.userId);
            if (step.approverId) ids.push(step.approverId);
            return ids;
        });

        const users = await this.usersService.findByPks([...new Set(userIds)], { instanceId });
        if (users.length) {
            await this.attachmentsService.addSignedUrlToModelWithImages(instanceId, users, ['profilePicture']);
        }

        const usersById: Record<number, (typeof users)[number]> = {};
        for (const user of users) usersById[user.id] = user;

        for (const step of approvalSteps) {
            step.approver = step.approverId ? (usersById[step.approverId] ? userDAOtoUser(usersById[step.approverId]) : null) : null;
            for (const pa of step.potentialApprovers) {
                const paDao = usersById[pa.userId];
                if (paDao) pa.user = userDAOtoUser(paDao);
            }
        }

        return approvalSteps;
    };

    public getCategorizedHourStatus = async (id: string, instanceId: number): Promise<TimeTrackingCategorizedHourStatus> => {
        return this.categorizedHourRepository.getCategorizedHourStatus(id, instanceId);
    };

    public getCategorizedHourIdsForApprover = async (userId: number, instanceId: number): Promise<string[]> => {
        return this.approvalStepRepository.getCategorizedHourIdsForApprover(userId, instanceId);
    };

    public getStepIdsWhereUserIsPotentialApprover = async (userId: number, stepIds: string[]): Promise<string[]> => {
        return this.approvalStepRepository.getStepIdsWhereUserIsPotentialApprover(userId, stepIds);
    };

    public getCurrentStepIdsByCategorizedHourIds = async (categorizedHourIds: string[]): Promise<Map<string, string>> => {
        return this.approvalStepRepository.getCurrentStepIdsByCategorizedHourIds(categorizedHourIds);
    };

    /**
     * Deletes the approval data for a list of categorized hours. This is used when a categorized hour is deleted.
     * Approval data includes the approval steps and the potential approvers for each step.
     * @param categorizedHourIds - The IDs of the categorized hours to delete the approval data for.
     */
    public deleteApprovalDataByCategorizedHourIds = async (categorizedHourIds: string[]): Promise<void> => {
        if (categorizedHourIds.length === 0) return;
        await this.approvalStepRepository.deleteApprovalDataByCategorizedHourIds(categorizedHourIds);
    };

    /**
     * Resolves the concrete user IDs that can approve a given step.
     * Called once per step during snapshot creation (see createApprovalStepsSnapshot).
     *
     * A single step can combine multiple approver sources; all resolved IDs are merged
     * and deduplicated. If no source matches, the step has no potential approvers
     * and only an admin can advance it.
     *
     * @param policyStep - The policy-level step configuration that defines who can approve.
     * @param userId     - The employee whose categorized hour is being approved (used to resolve relational approvers).
     * @param instanceId - The instance scope for org chart / segmentation lookups.
     */
    private resolveApproverIds = async (policyStep: TimeTrackingPolicyApprovalStep, userId: number, instanceId: number): Promise<number[]> => {
        const approverIds: number[] = [];

        // Org chart relations: direct boss and/or boss-of-boss of the employee
        if (policyStep.responsibleRelations?.length) {
            const relations = policyStep.responsibleRelations.map((r) => r.relation);
            let bossIds: number[] | null = null;

            if (relations.includes('BOSS')) {
                bossIds = await this.organizationChartsService.getBossIds(userId, instanceId);
                approverIds.push(...bossIds);
            }

            if (relations.includes('SECOND_BOSS')) {
                if (!bossIds) bossIds = await this.organizationChartsService.getBossIds(userId, instanceId);
                if (bossIds.length > 0) {
                    const secondBossMap = await this.organizationChartsService.getBossesOfUsers(bossIds, instanceId);
                    const secondBossIds = Object.values(secondBossMap)
                        .flat()
                        .map((user) => user.getId());
                    approverIds.push(...secondBossIds);
                }
            }
        }

        // Segmentation-based: all users belonging to the configured segmentation items
        if (policyStep.responsibleSegmentationItems?.length) {
            const itemIds = policyStep.responsibleSegmentationItems.map((item) => item.segmentationItemId);
            const itemUserIds = await this.segmentationsService.getUserIdsBySegmentationItems(itemIds);
            approverIds.push(...itemUserIds);
        }

        // Explicitly assigned users
        if (policyStep.responsibleUsers?.length) {
            approverIds.push(...policyStep.responsibleUsers.map((u) => u.userId));
        }

        // Designated reviewer: a specific reviewer assigned to the employee
        if (policyStep.designatedReviewer) {
            const designatedReviewerId = await this.usersService.getReviewerIdOfUser(userId);
            if (designatedReviewerId) approverIds.push(designatedReviewerId);
        }

        return [...new Set(approverIds)];
    };
}
