import {
    allPromises,
    BaseAdapter,
    BasePort,
    DatabaseError,
    ErrorCodes,
    EventEmitterPort,
    Events,
    IllegalStateError,
    ISO_DATE,
    ISOLATION_LEVELS,
    LoggerPort,
    moment,
    NotFoundError,
    OptionalLimitOffsetPagination,
    OrderItem,
    RegionHolidayParams,
    RegionHolidayUpdateParams,
    RegionUsersParams,
    RequestUnprocessableEntityError,
    ResourceLockedError,
    TimeOffRequestParams,
    TimeTrackingPolicyAssignedParams,
    TimeTrackingPolicyUnassignedParams,
    TimeTrackingPolicyUpdateParams,
    toWorkday,
    WORKDAYS,
} from '@humand-packages/common';
import { UserDAO } from 'humand-packages/monolith/src/api/modules/users/infrastructure/dataAccessObjects/userDAO';
import _ from 'lodash';
import { DateTime } from 'luxon';
import { Moment } from 'moment';
import { Transaction } from 'sequelize';
import { Service } from 'typedi';
import { getGlobalNamespace } from '../../../../config/namespace';
import { PortsDI } from '../../../../config/portsDI';
import { UserBotApp } from '../../../botApps/business/models/botApp';
import { FeatureFlagCode } from '../../../featureFlags/business/enums/featureFlagCode';
import { FeatureFlagService } from '../../../featureFlags/business/services/featureFlagService';
import { Instance } from '../../../instances/business/models/instance';
import { InstancesService } from '../../../instances/business/services/instancesService';
import { Holiday } from '../../../regions/business/models/holiday';
import { Site } from '../../../regions/business/models/site';
import { RegionsService } from '../../../regions/business/services/regionService';
import { SiteService } from '../../../regions/business/services/sitesService';
import { TimeOffRequest } from '../../../timeOff/business/models/requests/timeOffRequest';
import { TimeOffUser } from '../../../timeOff/business/models/users/timeOffUser';
import { TimeOffListRequestsService } from '../../../timeOff/business/services/timeOffListRequestsService';
import { TIME_OFF_POLICY_COUNTING_METHODS } from '../../../timeOff/business/types/policies/countingMethods';
import { TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES } from '../../../timeOff/business/types/policyTypes/activityTypes';
import { TIME_OFF_REQUEST_ROLES } from '../../../timeOff/business/types/requests/roles';
import { TIME_OFF_REQUEST_CONSUMPTION_TYPES } from '../../../timeOff/business/types/requests/timeOffRequestConsumptionTypes';
import { TIME_OFF_REVIEW_STATES } from '../../../timeOff/business/types/states';
import { UserInterface } from '../../../users/business/models/userInterface';
import { UsersService } from '../../../users/business/services/usersService';
import { Incidence } from '../../infrastructure/constants/incidences';
import { NightShiftAssignment } from '../../infrastructure/constants/setting';
import { State } from '../../infrastructure/constants/state';
import { AutoCloseEntryStatus } from '../../infrastructure/constants/timeTracking/autoCloseEntryStatus';
import { mapModelEntryToApiEntry } from '../../infrastructure/mappers/timeTrackingEntryApiMapper';
import { TimeTrackingEntriesRepositoryPort } from '../../infrastructure/ports/timeTracking/timeTrackingEntriesRepositoryPort';
import { TimeTrackingRepositoryPort } from '../../infrastructure/ports/TimeTrackingRepositoryPort';
import { CreateTimeTrackingEntryRequestsVC } from '../../presentation/validationClasses/createTimeTrackingEntryRequestsVC';
import { TimeTrackingGetNotificationScheduleVC } from '../../presentation/validationClasses/timeTracking/timeTrackingGetNotificationScheduleVC';
import { CategorizedHoursActionType } from '../constants/categorizedHoursActionTypes';
import { MAX_DAYS_FROM_NOW_SCHEDULE } from '../constants/timeTracking';
import { AutoCloseFallback } from '../enum/timeTracking/autoCloseFallback';
import { deriveAutoCloseFallback, getAutoCloseData } from '../helpers/autoCloseHelper';
import { hasLocationIncidence } from '../helpers/siteHelper';
import { calculateWorkedHours } from '../helpers/timeTrackingEntryHelper';
import { GridFilterRequest } from '../interfaces/shiftManagement/gridFiltersInterface';
import { TimeTrackingDaySummaryWithInstanceFilters } from '../interfaces/timeTrackingDaySummaryFilters';
import { TimeTrackingEntriesApiFilter } from '../interfaces/timeTrackingEntriesApiFilter';
import { TimeTrackingDaySummary } from '../models/daySummary';
import { TimeTrackingDaySummaryHours } from '../models/daySummaryHours';
import { TimeTrackingDaySummaryScheduleData } from '../models/daySummarySchedule';
import { ShiftAssignedDay } from '../models/shiftManagement/shiftAssignedDay';
import { AutoCloseNotificationBatch, SummaryDataToCorrect, SummaryDataToUpdate, EntryDataToDelete } from '../models/timeTracking/autoCloseData';
import { TimeTrackingAutoCloseEntry } from '../models/timeTracking/autoCloseEntry';
import { TIME_FORMAT, TimeSlot } from '../models/timeTracking/scheduleData';
import {
    TimeSlotIsoDatetime,
    TimeTrackingNotificationSchedule,
    TimeTrackingNotificationScheduleItem,
} from '../models/timeTracking/timeTrackingNotificationSchedule';
import { TimeTrackingDaySummaryHoliday } from '../models/timeTrackingDaySummaryHoliday';
import { TimeTrackingDaySummaryTimeOff } from '../models/timeTrackingDaySummaryTimeOff';
import { TimeTrackingEntry, TimeTrackingEntryApi, TimeTrackingEntryPrototype, TimeTrackingEntryUpdate } from '../models/timeTrackingEntry';
import { TimeTrackingPolicy, TimeTrackingPolicyAssociation } from '../models/timeTrackingPolicy';
import { TimeTrackingSetting } from '../models/timeTrackingSetting';
import { AssignedWorkSchedule, ScheduledDays, WorkSchedule } from '../models/workSchedule';
import ScheduleCategorizedHoursPort from '../ports/scheduleCategorizedHoursPort';
import { TimeTrackingKioskPort } from '../ports/timeTrackingKioskPort';
import TimeTrackingNotificationsPort from '../ports/timeTrackingNotificationsPort';
import { TimeTrackingPoliciesPort } from '../ports/timeTrackingPoliciesPort';
import WorkScheduleServicePort from '../ports/workScheduleServicePort';
import { isEntryTypeEnd, TimeTrackingEntryType } from '../types/entry';
import { DaySummaryScheduleSource } from '../types/scheduleSource';
import { isSourceAutoClosed, isSourceDayChange, isUserGeneratedSource, TimeTrackingEntrySource } from '../types/source';
import { TimeTrackingEmployeeStatus } from '../types/status';
import TimeTrackingSettingService from './setting';
import ShiftAssignedDayService from './shiftManagement/shiftAssignedDay';

export default abstract class TimeTrackingService extends BasePort {
    abstract createEntry(entry: TimeTrackingEntryPrototype, instance: Instance, loggedBotApp: UserBotApp | undefined): Promise<TimeTrackingEntry>;
    abstract createEntryTransactional(
        loggedUser: UserDAO,
        instance: Instance,
        params: CreateTimeTrackingEntryRequestsVC,
        loggedBotApp: UserBotApp | undefined,
        hasTimeTrackingDesertEagleEnabledFF: boolean,
        externalDesertEagleEntryId?: string,
    ): Promise<TimeTrackingEntry>;

    abstract createDesertEagleEntry(loggedUser: UserDAO, params: CreateTimeTrackingEntryRequestsVC): Promise<string>;

    abstract updateEntries(
        instance: Instance,
        user: UserInterface,
        summaryId: number,
        updatingEntries: TimeTrackingEntryUpdate[],
        hasTimeTrackingDesertEagleEnabledFF: boolean,
    ): Promise<TimeTrackingDaySummary>;

    /* Expecting entries to be sorted newest first */
    abstract syncEntries(
        entries: TimeTrackingEntryPrototype[],
        userId: number,
        instance: Instance,
        loggedBotApp: UserBotApp | undefined,
    ): Promise<TimeTrackingEntry[]>;

    /* Lists same day entries of user for given date and timezone, respecting pagination */
    abstract listDayEntries(userId: number, date: string, instance: Instance): Promise<TimeTrackingEntry[]>;

    /** Returns true if the user has any time tracking entry on a day strictly after `dateString` (instance TZ). */
    abstract hasEntriesAfterDate(userId: number, dateString: string, instance: Instance): Promise<boolean>;

    /** Deletes an entry (e.g. autoclose) for Public API flow; syncs to Desert Eagle if enabled. Does not update day summary. */
    abstract deleteEntryForPublicApi(entry: TimeTrackingEntry, deletedByUserId: number, instance: Instance): Promise<void>;

    /** Deletes all entries of a user inside the given date range and refreshes the affected day summaries. */
    abstract deleteEntriesInRangeForPublicApi(
        instance: Instance,
        actor: UserInterface,
        targetUserId: number,
        startDate: string,
        endDate: string,
    ): Promise<void>;

    abstract closeEntriesForDate(toDate: Date, fromDate: Date, limit: number): Promise<{ autoCloseBatches: AutoCloseNotificationBatch[] }>;

    abstract getOrCreateDaySummariesByUser(
        userId: number,
        instance: Instance,
        fromDate: string,
        toDate: string,
        includeEntries?: boolean,
    ): Promise<TimeTrackingDaySummary[]>;

    abstract getUserActiveStatus(userId: number, instance: Instance): Promise<{ day: string; status: TimeTrackingEmployeeStatus }>;
    abstract deleteDaySummariesForUsers(userIds: number[], fromDate: string, endDate?: string): Promise<void>;
    abstract searchUserIdsByDaySummaries(filters: TimeTrackingDaySummaryWithInstanceFilters): Promise<number[]>;
    abstract getEntriesOfInstance(instanceId: number, fromDate: Date, limit: number): Promise<{ count: number; rows: TimeTrackingEntry[] }>;
    abstract filterUsersBySites(
        instanceId: number,
        userIds: number[],
        fromDate: string,
        toDate: string,
        siteIds?: number[],
        excludeSiteIds?: number[],
    ): Promise<number[]>;

    abstract listEntriesApi(
        instance: Instance,
        params: TimeTrackingEntriesApiFilter,
        pagination: OptionalLimitOffsetPagination,
    ): Promise<{ count: number; rows: TimeTrackingEntryApi[] }>;

    abstract createMissingSummariesForUsers(userIds: number[], from: string, to: string, instance: Instance): Promise<void>;
    abstract getNotificationSchedule(
        userId: number,
        instance: Instance,
        params: TimeTrackingGetNotificationScheduleVC,
    ): Promise<TimeTrackingNotificationSchedule>;

    abstract faceClock(
        instance: Instance,
        user: UserInterface,
        loggedBotApp: UserBotApp,
        hasTimeTrackingDesertEagleEnabledFeatureFlag: boolean,
    ): Promise<TimeTrackingEntry>;
}

export const TIME_PADDING = 2;
const UNDERWORKED_THRESHOLD = 0.5; // half an hour
const LATENESS_IN_MINUTES = 10;
const MINUTES_IN_HOUR = 60;
const INCIDENCES_SCHEDULE_DEPENDENT = [Incidence.UNDERWORKED, Incidence.LATE, Incidence.ABSENT];
const DUPLICATE_TYPE_ERROR = "Can't create two entries of the same type in a row";
const CATEGORIZED_HOURS_SCHEDULE_DELAY_MINUTES = 30;

@Service()
export class TimeTrackingServiceAdapter extends BaseAdapter implements TimeTrackingService {
    constructor(
        @PortsDI(TimeTrackingRepositoryPort) private readonly timeTrackingRepository: TimeTrackingRepositoryPort,
        @PortsDI(WorkScheduleServicePort) private readonly workScheduleService: WorkScheduleServicePort,
        private readonly siteService: SiteService,
        private readonly regionService: RegionsService,
        private readonly timeOffListRequestsService: TimeOffListRequestsService,
        private readonly eventEmitter: EventEmitterPort,
        private readonly instanceService: InstancesService,
        private readonly usersService: UsersService,
        @PortsDI(TimeTrackingPoliciesPort) private readonly timeTrackingPoliciesService: TimeTrackingPoliciesPort,
        @PortsDI(TimeTrackingKioskPort) private readonly timeTrackingKioskService: TimeTrackingKioskPort,
        private readonly logger: LoggerPort,
        private readonly shiftAssignedDayService: ShiftAssignedDayService,
        private readonly timeTrackingSettingService: TimeTrackingSettingService,
        @PortsDI(TimeTrackingNotificationsPort) private readonly timeTrackingNotificationsService: TimeTrackingNotificationsPort,
        @PortsDI(TimeTrackingEntriesRepositoryPort) private readonly timeTrackingEntriesRepository: TimeTrackingEntriesRepositoryPort,
        private readonly featureFlagService: FeatureFlagService,
        @PortsDI(ScheduleCategorizedHoursPort) private readonly scheduleCategorizedHoursPort: ScheduleCategorizedHoursPort,
    ) {
        super();
        this.setupEventHandlers();
    }

    private setupEventHandlers() {
        this.eventEmitter.listenEvent(Events.TIME_OFF_REQUEST_APPROVED, this.handleTimeOffApproval);
        this.eventEmitter.listenEvent(Events.TIME_OFF_REQUEST_CANCELLED, this.handleTimeOffCancellation);
        this.eventEmitter.listenEvent(Events.REGION_HOLIDAY_CREATED, this.handleHolidayCreation);
        this.eventEmitter.listenEvent(Events.REGION_HOLIDAY_DELETED, this.handleHolidayDeletion);
        this.eventEmitter.listenEvent(Events.REGION_HOLIDAY_UPDATED, this.handleHolidayUpdate);
        this.eventEmitter.listenEvent(Events.WORK_SCHEDULE_NEW_USERS, this.handleScheduleChange);
        this.eventEmitter.listenEvent(Events.TIME_TRACKING_POLICY_UPDATED, this.handleTimeTrackingPolicyUpdate);
        this.eventEmitter.listenEvent(Events.TIME_TRACKING_POLICY_UNASSIGNED, this.handleTimeTrackingPolicyUnassigned);
        this.eventEmitter.listenEvent(Events.TIME_TRACKING_POLICY_ASSIGNED, this.handleTimeTrackingPolicyAssigned);
        this.eventEmitter.listenEvent(Events.INSTANCE_UPDATE_TIMEZONE, this.handleTimezoneChange);
        this.eventEmitter.listenEvent(Events.SHIFT_ASSIGNED_DAY_CREATED, this.handleShiftAssignedDayCreation);
        this.eventEmitter.listenEvent(Events.REGION_USERS_ASSIGNED, this.handleRegionUsersAssigned);
        this.eventEmitter.listenEvent(Events.REGION_USERS_UNASSIGNED, this.handleRegionUsersUnassigned);
    }

    private setKioskData = async (entry: TimeTrackingEntryPrototype, instance: Instance, loggedBotApp: UserBotApp | undefined): Promise<void> => {
        if (loggedBotApp) {
            const kiosk = await this.timeTrackingKioskService.getByBotAppId(loggedBotApp.id, instance.getId());
            if (kiosk) {
                entry.kioskId = kiosk.id;
                entry.siteId = kiosk.siteId ?? undefined;
            }
        }
    };

    private scheduleCategorizedHoursCalculation = async (
        params: { dateStrings: string[]; instanceId: number; userIds: number[] },
        instanceTimezone: string,
    ): Promise<void> => {
        const delay = DateTime.now().plus({ minutes: CATEGORIZED_HOURS_SCHEDULE_DELAY_MINUTES }).setZone(instanceTimezone).toJSDate();
        await this.scheduleCategorizedHoursPort.schedule({ payload: params, type: CategorizedHoursActionType.CALCULATE_CATEGORIZED_HOURS }, delay);
    };

    public createEntry = async (
        entry: TimeTrackingEntryPrototype,
        instance: Instance,
        loggedBotApp: UserBotApp | undefined,
    ): Promise<TimeTrackingEntry> => {
        const site = entry.siteId ? await this.siteService.getById(entry.siteId, instance.getId()) : undefined;
        const currentDate = moment(entry.time).tz(instance.getTimezone());
        //If the request become from a kiosk, store his id
        await this.setKioskData(entry, instance, loggedBotApp);
        return await this.timeTrackingRepository.getTransaction(async () => {
            const date = currentDate.format(ISO_DATE);
            const daySummaries = await this.getOrCreateDaySummariesByUser(
                entry.userId,
                instance,
                currentDate.subtract(1, 'day').format(ISO_DATE),
                date,
            );
            const daySummary = this.chooseDaySummary(daySummaries);
            const siteIds = new Set<number>();
            daySummary.timeTrackingEntries.forEach((entry) => entry.siteId && siteIds.add(entry.siteId));
            if (site) siteIds.delete(site.getId());
            const sites = siteIds.size ? await this.siteService.getByIds(Array.from(siteIds), instance.getId(), true) : [];
            if (site) sites.push(site);
            entry.summaryId = daySummary.id;
            const createdEntry = await this.timeTrackingRepository.createEntry(entry);
            daySummary.timeTrackingEntries.push(createdEntry);
            await this.updateDaySummary(instance, daySummary, sites);
            if (createdEntry.type === TimeTrackingEntryType.END) {
                await this.scheduleCategorizedHoursCalculation(
                    { dateStrings: [daySummary.dateString], instanceId: instance.getId(), userIds: [createdEntry.userId] },
                    instance.getTimezone(),
                );
            }
            return createdEntry;
        });
    };

    public updateEntries = async (
        instance: Instance,
        user: UserInterface,
        summaryId: number,
        updatingEntries: TimeTrackingEntryUpdate[],
        hasTimeTrackingDesertEagleEnabledFF: boolean,
    ): Promise<TimeTrackingDaySummary> => {
        updatingEntries.sort(this.sortEntriesTimeAscending);
        const daySummary = await this.timeTrackingRepository.getDaySummaryById(instance.getId(), summaryId);
        if (!daySummary) throw new NotFoundError('Day summary not found', ErrorCodes.TIME_TRACKING_DAY_SUMMARY_NOT_FOUND);
        const error = this.updatingEntriesValidation(updatingEntries, daySummary);
        if (error) throw error;
        if (updatingEntries.some((entry) => moment.tz(entry.time, instance.getTimezone()).diff(daySummary.dateString, 'day') > 1)) {
            throw new RequestUnprocessableEntityError(
                'Cannot create entries from completely different days', // night shifts allow some day difference
                ErrorCodes.TIME_TRACKING_ENTRY_UPDATE_ERROR,
            );
        }
        let { deletedEntries, modifiedEntries, newEntries, nonModifiedEntries } = this.classifyEntriesForUpdate(
            updatingEntries,
            daySummary.timeTrackingEntries,
        );

        const hasEntriesToSync = deletedEntries.length > 0 || modifiedEntries.length > 0 || newEntries.length > 0;
        const userHasFutureEntries =
            hasTimeTrackingDesertEagleEnabledFF && hasEntriesToSync
                ? await this.hasEntriesAfterDate(daySummary.userId, daySummary.dateString, instance)
                : false;
        const shouldSyncToDesertEagle = hasTimeTrackingDesertEagleEnabledFF && hasEntriesToSync && !userHasFutureEntries;

        if (hasTimeTrackingDesertEagleEnabledFF && !shouldSyncToDesertEagle) {
            const reason = !hasEntriesToSync ? 'no entry changes to sync' : 'user has entries after edited day';
            this.logger.info(
                { dateString: daySummary.dateString, instanceId: instance.getId(), userId: daySummary.userId },
                `Skipping Desert Eagle sync for updateEntries — ${reason}`,
            );
        }

        if (shouldSyncToDesertEagle) {
            const syncResult = await this.timeTrackingEntriesRepository.updateEntries(instance.getId(), deletedEntries, modifiedEntries, newEntries);
            if (!syncResult.success) {
                this.logger.error({ syncResult }, 'Failed to sync entries');
                throw new Error(`Failed to sync entries: ${syncResult.message}`);
            }
            // Map the entries with the deagle entry ids
            newEntries = newEntries.map((entry, index) => {
                const desertEagleEntryId = syncResult.createdEntries?.[index]?.entryId;
                return { ...entry, desertEagleEntryId };
            });
            modifiedEntries = modifiedEntries.map((entry, index) => {
                const desertEagleEntryId = syncResult.updatedEntries?.[index]?.entryId;
                return { ...entry, desertEagleEntryId };
            });
        }
        const newEntryPrototypes = newEntries.map((entry) => this.mapNewEntryToPrototype(entry, user, summaryId));
        const modifiedEntryUpdates = modifiedEntries.map((entry) => this.mapModifiedEntryToUpdate(entry, user, summaryId));
        return await this.timeTrackingRepository.getTransaction(async () => {
            await allPromises(
                deletedEntries.map((entry) => () => this.timeTrackingRepository.deleteEntry(this.mapDeletedEntryToUpdate(entry, user.getId()))),
            );

            const results = await allPromises([
                ...newEntryPrototypes.map((entry) => () => this.timeTrackingRepository.createEntry(entry)),
                ...modifiedEntryUpdates.map((entry) => () => this.timeTrackingRepository.updateEntry(entry)),
                ...nonModifiedEntries.map((entry) => () => Promise.resolve(entry)),
            ]);
            daySummary.timeTrackingEntries = results.filter((e): e is TimeTrackingEntry => e !== undefined);
            const siteIds = new Set<number>();
            daySummary.timeTrackingEntries.forEach((entry) => entry.siteId && siteIds.add(entry.siteId));
            const sites = siteIds.size ? await this.siteService.getByIds(Array.from(siteIds), instance.getId(), false) : [];
            const siteByIds = new Map(sites.map((site) => [site.getId(), site]));
            daySummary.timeTrackingEntries.forEach((entry) => (entry.locationIncidence = hasLocationIncidence(entry, siteByIds)));
            if (deletedEntries.length > 0 || modifiedEntries.length > 0 || newEntryPrototypes.length > 0) {
                await this.scheduleCategorizedHoursPort.schedule({
                    payload: {
                        dateString: daySummary.dateString,
                        instanceId: instance.getId(),
                        userId: daySummary.userId,
                    },
                    type: CategorizedHoursActionType.ENTRIES_CHANGE,
                });
            }
            return await this.updateDaySummary(instance, daySummary, sites);
        });
    };

    public syncEntries = async (
        entries: TimeTrackingEntryPrototype[],
        userId: number,
        instance: Instance,
        loggedBotApp: UserBotApp | undefined,
    ): Promise<TimeTrackingEntry[]> => {
        if (entries.length === 0) {
            return [];
        }
        const timezone = instance.getTimezone();
        const sites = new Set<number>();
        entries.sort(this.sortEntriesTimeAscending);
        const fromDate = moment.tz(entries[0].time, timezone).subtract(1, 'day').format(ISO_DATE);
        const toDate = moment.tz(entries[entries.length - 1].time, timezone).format(ISO_DATE);
        const daySummaries = await this.getOrCreateDaySummariesByUser(userId, instance, fromDate, toDate, true);
        const summariesByDate = new Map<string, TimeTrackingDaySummary>();
        daySummaries.forEach((summary) => summariesByDate.set(summary.dateString, summary));
        const entriesByDate = new Map<string, TimeTrackingEntryPrototype[]>();
        for (let index = 0; index < entries.length; index++) {
            const entry = entries[index];
            if (moment(entry.time).isAfter(moment()))
                throw new RequestUnprocessableEntityError('Cannot create entries in the future', ErrorCodes.TIME_TRACKING_SYNC_ERROR);
            if (entry.siteId) sites.add(entry.siteId);
            if (!isUserGeneratedSource(entry.source)) continue;
            await this.setKioskData(entry, instance, loggedBotApp);
            const date = moment.tz(entry.time, timezone).format(ISO_DATE);
            const previousDate = moment.tz(entry.time, timezone).subtract(1, 'day').format(ISO_DATE);
            const candidateSummaries = [summariesByDate.get(previousDate), summariesByDate.get(date)].filter(Boolean) as TimeTrackingDaySummary[];
            const daySummary = this.chooseDaySummary(candidateSummaries);
            entriesByDate.set(daySummary.dateString, [...(entriesByDate.get(daySummary.dateString) ?? []), entry]);
        }
        const foundSites = await this.siteService.getByIds(Array.from(sites), instance.getId(), true);
        const results = await allPromises(
            Array.from(entriesByDate.entries()).map(([date, entries]) => async () => {
                const daySummary = summariesByDate.get(date);
                if (!daySummary || !entries?.length) return [];
                const previousDate = moment.tz(date, timezone).subtract(1, 'day').format(ISO_DATE);
                const previousDaySummary = summariesByDate.get(previousDate);
                const previousDayEntries = previousDaySummary?.timeTrackingEntries ?? [];
                const previousDayAutoCloseEntry =
                    previousDayEntries.length && isSourceAutoClosed(previousDayEntries[0].source) ? previousDayEntries[0] : undefined;
                return this.syncDayEntries(daySummary, entries, instance, foundSites, previousDayAutoCloseEntry);
            }),
        );
        return results.flat();
    };

    private shouldNotComputeIncidences = (timeOffRequests: TimeTrackingDaySummaryTimeOff[] | undefined): boolean => {
        // If at least a time off request that is not in hours is non_work, we should not compute incidences
        return (
            timeOffRequests?.some(
                (request) =>
                    request.activityType !== TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES.NON_WORK &&
                    request.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HOURS,
            ) ?? false
        );
    };

    private syncDayEntries = async (
        daySummary: TimeTrackingDaySummary,
        entries: TimeTrackingEntryPrototype[],
        instance: Instance,
        sites: Site[],
        previousDayAutoCloseEntry?: TimeTrackingEntry,
    ) => {
        const existingEntries = daySummary.timeTrackingEntries.sort(this.sortEntriesTimeAscending);
        let filteredEntries = [...entries];
        // filter already present entries
        if (existingEntries.length && entries.length) {
            filteredEntries = entries.filter((entry) => !existingEntries.find((prev) => moment(prev.time).isSame(entry.time)));
        }

        // filter END entry that comes after an AUTO_CLOSE entry from the previous day
        if (previousDayAutoCloseEntry && filteredEntries.length) {
            const possibleOverlappingEntry = filteredEntries[0];
            if (isEntryTypeEnd(possibleOverlappingEntry.type) && moment(possibleOverlappingEntry.time).isAfter(previousDayAutoCloseEntry.time)) {
                filteredEntries.shift();
            }
        }
        // filter END entry that comes after an AUTO_CLOSE entry from the same day
        if (existingEntries.length && filteredEntries.length) {
            const lastExistingEntry = existingEntries[existingEntries.length - 1];
            const firstNewEntry = filteredEntries[0];
            if (
                isSourceAutoClosed(lastExistingEntry.source) &&
                isEntryTypeEnd(firstNewEntry.type) &&
                moment(firstNewEntry.time).isAfter(lastExistingEntry.time)
            ) {
                filteredEntries.shift();
            }
        }

        if (filteredEntries.length === 0) {
            return [];
        }
        filteredEntries.forEach((entry) => {
            if (entry.siteId && !sites.find((site) => site.getId() === entry.siteId)) entry.siteId = undefined;
        });

        const firstNewEntry = filteredEntries[0];
        const entriesToDelete = existingEntries.filter(
            (entry) => moment(entry.time).isAfter(firstNewEntry.time) && entry.source === TimeTrackingEntrySource.AUTO_CLOSE,
        );
        if (entriesToDelete.length) {
            daySummary.timeTrackingEntries = daySummary.timeTrackingEntries.filter((entry) => !entriesToDelete.includes(entry));
        }
        const newFullEntries = [...existingEntries.filter((entry) => !entriesToDelete.includes(entry)), ...filteredEntries];

        const hasTimeTrackingDesertEagleEnabledFF = await this.getTimeTrackingDesertEagleEnabledFeatureFlag(instance.getId());
        if (hasTimeTrackingDesertEagleEnabledFF && (entriesToDelete.length > 0 || filteredEntries.length > 0)) {
            const syncResult = await this.timeTrackingEntriesRepository.updateEntries(instance.getId(), entriesToDelete, [], filteredEntries);
            if (!syncResult.success) {
                this.logger.error({ syncResult }, 'Failed to sync entries');
                throw new Error(`Failed to sync entries: ${syncResult.message}`);
            }
            // Map the entries with the deagle entry ids
            filteredEntries = filteredEntries.map((entry, index) => {
                const desertEagleEntryId = syncResult.createdEntries?.[index]?.entryId;
                return { ...entry, desertEagleEntryId };
            });
        }
        daySummary.workedHours = calculateWorkedHours(newFullEntries); // sorts descending
        newFullEntries.sort(this.sortEntriesTimeAscending);
        daySummary.incidences = this.shouldNotComputeIncidences(daySummary.timeOffRequests)
            ? []
            : this.computeMultipleEntriesIncidences(
                  daySummary.estimatedHours,
                  daySummary.workedHours,
                  newFullEntries,
                  instance.getTimezone(),
                  daySummary.timeSlots ?? [],
                  sites,
                  daySummary.latenessTolerance,
              );
        let autoCloseTime: Date | undefined;
        let autoCloseEntryTime: Date | undefined;
        if (!existingEntries.some(this.isStartEntryByUser) && this.isStartEntryByUser(firstNewEntry)) {
            const policy = await this.getUserCurrentPolicy(instance.getId(), daySummary.userId, daySummary.dateString);
            const autoCloseData = getAutoCloseData(policy, {
                estimatedHours: daySummary.estimatedHours,
                timeSlots: daySummary.timeSlots ?? null,
                dateString: daySummary.dateString,
                timezone: instance.getTimezone(),
                allEntries: [...existingEntries, ...entries],
            });
            autoCloseTime = autoCloseData.autoCloseTime;
            autoCloseEntryTime = autoCloseData.autoCloseEntryTime;
        }
        return this.timeTrackingRepository
            .getTransaction(async () => {
                await allPromises(
                    entriesToDelete.map((entry) => () => this.timeTrackingRepository.deleteEntry(this.mapDeletedEntryToUpdate(entry, entry.userId))),
                );
                await this.timeTrackingRepository.updateDaySummaryAfterEntryChanges(
                    daySummary.id,
                    daySummary.workedHours,
                    daySummary.incidences,
                    newFullEntries[newFullEntries.length - 1].type === TimeTrackingEntryType.END ? State.CLOSED : State.OPEN,
                    autoCloseTime,
                    autoCloseEntryTime,
                );
                const summaryId = daySummary.id;
                filteredEntries.forEach((entry) => {
                    entry.summaryId = summaryId;
                    entry.updatedBy = entry.updatedBy ?? entry.userId;
                    entry.createdBy = entry.createdBy ?? entry.userId;
                });

                // Schedule categorized hours calculation for end entries
                const endEntries = filteredEntries.filter((entry) => entry.type === TimeTrackingEntryType.END);
                if (endEntries.length > 0) {
                    await this.scheduleCategorizedHoursCalculation(
                        {
                            dateStrings: [daySummary.dateString],
                            instanceId: instance.getId(),
                            userIds: [...new Set(endEntries.map((entry) => entry.userId))],
                        },
                        instance.getTimezone(),
                    );
                }
                return await this.timeTrackingRepository.createBulkEntries(filteredEntries);
            })
            .catch((e: any) => {
                if (DatabaseError.isUniqueConstraintError(e)) {
                    throw new RequestUnprocessableEntityError((e as Error).message, ErrorCodes.TIME_TRACKING_SYNC_ERROR);
                }
                throw DatabaseError.sameOrWrap(e);
            });
    };

    public listDayEntries = async (userId: number, date: string, instance: Instance): Promise<TimeTrackingEntry[]> => {
        const currentDate = moment.tz(date, instance.getTimezone());
        const daySummaries = await this.getOrCreateDaySummariesByUser(userId, instance, currentDate.subtract(1, 'day').format(ISO_DATE), date);
        const daySummary = this.chooseDaySummary(daySummaries);
        return daySummary.timeTrackingEntries;
    };

    private listEntries = async (
        userId: number,
        fromDate: Date,
        toDate: Date,
        pagination: OptionalLimitOffsetPagination,
    ): Promise<{ count: number; rows: TimeTrackingEntry[] }> => {
        return await this.timeTrackingRepository.listEntries(userId, fromDate, toDate, pagination);
    };

    public closeEntriesForDate = async (toDate: Date, fromDate: Date, limit: number): Promise<{ autoCloseBatches: AutoCloseNotificationBatch[] }> => {
        const autoCloseBatches: AutoCloseNotificationBatch[] = [];
        let result;
        do {
            result = await this.closeEntriesThatChangedDay(toDate, fromDate, limit);
            autoCloseBatches.push(...result.autoCloseBatches);
        } while (result.summariesUpdated >= limit);
        do {
            result = await this.closeEntriesLeftOpen(toDate, fromDate, limit);
            autoCloseBatches.push(...result.autoCloseBatches);
        } while (result.summariesUpdated >= limit);
        return { autoCloseBatches };
    };

    public getOrCreateDaySummariesByUser = async (
        userId: number,
        instance: Instance,
        fromDate: string,
        toDate: string,
        includeEntries = true,
    ): Promise<TimeTrackingDaySummary[]> => {
        const existingSummaries: TimeTrackingDaySummary[] = await this.timeTrackingRepository.getDaySummariesByUser(
            userId,
            fromDate,
            toDate,
            includeEntries,
        );
        existingSummaries.forEach((summary) => {
            if (summary.latenessTolerance === undefined) {
                summary.latenessTolerance = LATENESS_IN_MINUTES;
            }
        });
        const dates = this.generateDateRange(fromDate, toDate, instance.getTimezone());
        if (existingSummaries.length < dates.length) {
            const missingDates = dates
                .filter((date) => !existingSummaries.some((summary) => date.isSame(moment.tz(summary.dateString, instance.getTimezone()), 'day')))
                .sort((a, b) => a.diff(b));
            const missingSummaries = await this.createMissingSummariesForUser(userId, instance, missingDates);
            existingSummaries.push(...missingSummaries);
        }
        existingSummaries.sort((a, b) => a.dateString.localeCompare(b.dateString));
        return existingSummaries;
    };

    public getUserActiveStatus = async (userId: number, instance: Instance): Promise<{ day: string; status: TimeTrackingEmployeeStatus }> => {
        const now = moment.tz(instance.getTimezone());
        const previousDay = now.clone().subtract(1, 'day');
        const daySummaries = await this.getOrCreateDaySummariesByUser(userId, instance, previousDay.format(ISO_DATE), now.format(ISO_DATE));
        const daySummary = this.chooseDaySummary(daySummaries);
        const entries = daySummary.timeTrackingEntries.filter((entry) => now.isAfter(entry.time)).sort(this.sortEntriesTimeDescending);
        const isWorking = entries[0]?.type === TimeTrackingEntryType.START;
        if (isWorking) {
            return { day: daySummary.dateString, status: TimeTrackingEmployeeStatus.ACTIVE };
        }
        if (daySummary.isWorkday) {
            const hasEntries = !!daySummary.timeTrackingEntries?.length;
            const hasTimeSlots = !!daySummary.timeSlots?.length;
            const isInTimeSlot = hasTimeSlots ? daySummary.timeSlots?.some(this.isTimeInSlot(instance, now)) : false;

            if (!hasTimeSlots && !hasEntries) return { day: daySummary.dateString, status: TimeTrackingEmployeeStatus.NOT_CLOCKED_IN };
            if (hasTimeSlots && isInTimeSlot && !hasEntries) return { day: daySummary.dateString, status: TimeTrackingEmployeeStatus.NOT_CLOCKED_IN };
        }
        return { day: daySummary.dateString, status: TimeTrackingEmployeeStatus.INACTIVE };
    };

    public deleteDaySummariesForUsers = async (userIds: number[], fromDate: string, endDate?: string): Promise<void> => {
        if (userIds.length === 0) {
            return;
        }
        return await this.timeTrackingRepository.deleteDaySummariesForUsers(userIds, fromDate, endDate);
    };

    public searchUserIdsByDaySummaries = async (filters: TimeTrackingDaySummaryWithInstanceFilters): Promise<number[]> => {
        const instance = await this.instanceService.findByPk(filters.instanceId);
        if ((filters.incidences && filters.incidences.length) || filters.hasHolidays || filters.hasTimeOff) {
            // If summaries are missing, filtering will ignore them
            await this.createMissingSummariesForUsers(filters.userIds, filters.fromDate, filters.toDate, instance);
        }
        if (filters.status?.length && filters.status?.length > 0 && filters.status?.length < Object.values(TimeTrackingEmployeeStatus).length) {
            const today = moment.tz(instance.getTimezone()).format(ISO_DATE);
            await this.createMissingSummariesForUsers(filters.userIds, today, today, instance);
        }

        if (filters.onlyNoSchedule || filters.hasSchedule || filters.scheduleCodes?.length || filters.excludeScheduleCodes?.length) {
            const userIds = await this.workScheduleService.getUserIdsByScheduleCriteria(
                filters.instanceId,
                filters.userIds,
                filters.fromDate,
                filters.toDate,
                filters,
            );
            filters.userIds = userIds;
        }

        return this.timeTrackingRepository.searchUserIdsByDaySummaries(filters);
    };

    public getEntriesOfInstance = async (
        instanceId: number,
        fromDate: Date,
        limit: number,
    ): Promise<{ count: number; rows: TimeTrackingEntry[] }> => {
        return await this.timeTrackingRepository.getEntriesOfInstance(instanceId, fromDate, limit);
    };

    private updateDaySummary = async (instance: Instance, daySummary: TimeTrackingDaySummary, sites: Site[]) => {
        const lastEntry = daySummary.timeTrackingEntries.sort(this.sortEntriesTimeDescending)[0];
        let autoCloseTime: Date | undefined;
        let autoCloseEntryTime: Date | undefined;
        if (lastEntry && this.isStartEntryByUser(lastEntry)) {
            const policy = await this.getUserCurrentPolicy(instance.getId(), daySummary.userId, daySummary.dateString);
            const autoCloseData = getAutoCloseData(policy, {
                estimatedHours: daySummary.estimatedHours,
                timeSlots: daySummary.timeSlots ?? null,
                dateString: daySummary.dateString,
                timezone: instance.getTimezone(),
                allEntries: daySummary.timeTrackingEntries,
            });
            autoCloseTime = autoCloseData.autoCloseTime;
            autoCloseEntryTime = autoCloseData.autoCloseEntryTime;
        }
        daySummary.workedHours = calculateWorkedHours(daySummary.timeTrackingEntries);
        daySummary.incidences = this.shouldNotComputeIncidences(daySummary.timeOffRequests)
            ? []
            : this.computeMultipleEntriesIncidences(
                  daySummary.estimatedHours,
                  daySummary.workedHours,
                  daySummary.timeTrackingEntries,
                  instance.getTimezone(),
                  daySummary.timeSlots ?? [],
                  sites,
                  daySummary.latenessTolerance,
              );
        daySummary.state = !lastEntry || lastEntry.type === TimeTrackingEntryType.END ? State.CLOSED : State.OPEN;
        // Note extra hours is currently transient and thus can be ignored
        await this.timeTrackingRepository.updateDaySummaryAfterEntryChanges(
            daySummary.id,
            daySummary.workedHours,
            daySummary.incidences,
            daySummary.state,
            autoCloseTime,
            autoCloseEntryTime,
        );
        return daySummary;
    };

    private classifyEntriesForUpdate = (
        updatingEntries: Partial<TimeTrackingEntry>[],
        existingEntries: TimeTrackingEntry[],
    ): {
        deletedEntries: TimeTrackingEntry[];
        modifiedEntries: Partial<TimeTrackingEntry>[];
        newEntries: Partial<TimeTrackingEntry>[];
        nonModifiedEntries: TimeTrackingEntry[];
    } => {
        const existingEntriesMap = new Map<number, TimeTrackingEntry>(existingEntries.map((entry) => [entry.id, entry]));
        const deletedEntriesMap = new Map<number, TimeTrackingEntry>(existingEntriesMap);
        const newEntries: Partial<TimeTrackingEntry>[] = [];
        const modifiedEntries: Partial<TimeTrackingEntry>[] = [];
        const nonModifiedEntries: TimeTrackingEntry[] = [];
        updatingEntries.forEach((entry) => {
            if (!entry.id) {
                newEntries.push(entry);
            } else {
                const existingEntry = existingEntriesMap.get(entry.id);
                if (!existingEntry) {
                    throw new RequestUnprocessableEntityError(
                        'Trying to update an entry that is not assigned to the day',
                        ErrorCodes.TIME_TRACKING_ENTRY_NOT_FOUND,
                    );
                }
                deletedEntriesMap.delete(entry.id);
                if (this.entriesAreSame(entry, existingEntry)) {
                    modifiedEntries.push({ ...entry, desertEagleEntryId: existingEntry.desertEagleEntryId });
                } else {
                    nonModifiedEntries.push(existingEntry);
                }
            }
        });
        const deletedEntries = Array.from(deletedEntriesMap.values());
        return { deletedEntries, modifiedEntries, newEntries, nonModifiedEntries };
    };

    private mapNewEntryToPrototype = (entry: Partial<TimeTrackingEntry>, user: UserInterface, summaryId: number): TimeTrackingEntryPrototype => {
        return {
            comment: entry.comment,
            createdBy: user.getId(),
            desertEagleEntryId: entry.desertEagleEntryId,
            facialRecognitionSkipped: entry.facialRecognitionSkipped,
            instanceId: user.instanceId,
            isRemoteWork: entry.isRemoteWork,
            location: entry.location,
            siteId: entry.siteId,
            source: entry.source ?? TimeTrackingEntrySource.APP,
            summaryId,
            time: entry.time!,
            type: entry.type!,
            updatedBy: user.getId(),
            userId: entry.userId!,
        };
    };

    private mapModifiedEntryToUpdate = (entry: Partial<TimeTrackingEntry>, user: UserInterface, summaryId: number): Partial<TimeTrackingEntry> => {
        return {
            ...entry,
            desertEagleEntryId: entry.desertEagleEntryId,
            source: entry.source ?? TimeTrackingEntrySource.APP,
            summaryId,
            updatedBy: user.getId(),
        };
    };

    private mapDeletedEntryToUpdate = (entry: TimeTrackingEntry, deletedBy: number): TimeTrackingEntry => {
        return {
            ...entry,
            deletedBy,
        };
    };

    private updatingEntriesValidation = (
        updatingEntries: TimeTrackingEntryUpdate[],
        daySummary: TimeTrackingDaySummary,
    ): RequestUnprocessableEntityError | undefined => {
        let error;
        const existingEntriesMap = new Map<number, TimeTrackingEntry>(daySummary.timeTrackingEntries.map((entry) => [entry.id, entry]));
        updatingEntries.forEach((entry, i) => {
            if (entry.summaryId !== daySummary.id) {
                error = new RequestUnprocessableEntityError("Can't modify entries from different days", ErrorCodes.TIME_TRACKING_ENTRY_UPDATE_ERROR);
            }
            if (moment(entry.time).isAfter(moment())) {
                error = new RequestUnprocessableEntityError('Cannot create entries in the future', ErrorCodes.TIME_TRACKING_ENTRY_UPDATE_ERROR);
            }
            if (
                i > 0 &&
                (this.isNewOrTimeModifiedEntry(entry, existingEntriesMap) ||
                    this.isNewOrTimeModifiedEntry(updatingEntries[i - 1], existingEntriesMap))
            ) {
                if (entry.type === updatingEntries[i - 1].type) {
                    error = new RequestUnprocessableEntityError(
                        'Cannot create two entries of the same type in a row',
                        ErrorCodes.TIME_TRACKING_ENTRY_DUPLICATE,
                    );
                }
            }
        });
        return error;
    };

    private isNewOrTimeModifiedEntry = (entry: TimeTrackingEntryUpdate, existingEntriesMap: Map<number, TimeTrackingEntry>) => {
        if (!entry.id) return true;
        const existingEntry = existingEntriesMap.get(entry.id);
        if (!existingEntry) return true;
        return !existingEntry || (existingEntry && !this.entriesTimeAreSame(entry.time, existingEntry.time));
    };

    private entriesAreSame = (entry: Partial<TimeTrackingEntry>, existingEntry: TimeTrackingEntry) => {
        return (
            entry.type !== existingEntry.type ||
            (entry.time && entry.time.getTime() !== existingEntry.time.getTime()) ||
            (entry.siteId !== undefined && entry.siteId !== existingEntry.siteId) ||
            (entry.comment !== undefined && entry.comment !== existingEntry.comment) ||
            (entry.facialRecognitionSkipped !== undefined && entry.facialRecognitionSkipped !== existingEntry.facialRecognitionSkipped) ||
            (entry.isRemoteWork !== undefined && entry.isRemoteWork !== existingEntry.isRemoteWork) ||
            entry.location?.latitude !== existingEntry.location?.latitude ||
            entry.location?.longitude !== existingEntry.location?.longitude
        );
    };

    private entriesTimeAreSame = (time: Date, existingTime: Date) => {
        return moment(time).isSame(existingTime);
    };

    private isTimeInSlot(instance: Instance, now: Moment): (value: TimeSlot, index: number, array: TimeSlot[]) => unknown {
        return (slot) => {
            const start = moment.tz(slot.startTime, TIME_FORMAT, instance.getTimezone());
            const end = moment.tz(slot.endTime, TIME_FORMAT, instance.getTimezone());
            return now.isBetween(start, end, 'minutes', '[]');
        };
    }

    public async createMissingSummariesForUsers(userIds: number[], from: string, to: string, instance: Instance) {
        const dates = this.generateDateRange(from, to, instance.getTimezone());
        const usersWithMissingSummaries = await this.timeTrackingRepository.searchUserIdsWithMissingSummaries(userIds, from, to);
        const promises = usersWithMissingSummaries.map((id) => async () => {
            const existingSummaries = await this.timeTrackingRepository.getDaySummariesByUser(id, from, to);
            const missingDates = dates
                .filter((date) => !existingSummaries.some((summary) => date.isSame(moment.tz(summary.dateString, instance.getTimezone()), 'day')))
                .sort((a, b) => a.diff(b));
            if (missingDates.length) {
                await this.createMissingSummariesForUser(id, instance, missingDates);
            } else {
                this.logger.warn(
                    `No missing summaries found for user ${id}, but query thought there were some.\n Day summaries: ${existingSummaries
                        .map((a) => a.dateString)
                        .join(', ')}\n Dates: ${dates.map((d) => d.format(ISO_DATE)).join(', ')}`,
                );
            }
        });
        await allPromises(promises, 5);
    }

    private closeEntriesThatChangedDay = async (
        currentDate: Date,
        fromDate: Date,
        limit: number,
    ): Promise<{ summariesUpdated: number; autoCloseBatches: AutoCloseNotificationBatch[] }> => {
        const entriesToCreate: TimeTrackingAutoCloseEntry[] = [];
        const summariesToUpdate: SummaryDataToUpdate[] = [];
        const entriesToDelete: EntryDataToDelete[] = [];
        const summariesToCorrect: SummaryDataToCorrect[] = [];
        const summariesToClose = await this.timeTrackingRepository.getDaySummariesForDayChange(currentDate, fromDate, limit);
        const summariesToRecreate = summariesToClose.filter((summary) => summary.isDeleted);
        const summariesToProcess = summariesToClose.filter((summary) => !summary.isDeleted);
        if (summariesToRecreate.length) {
            const groupedDaySummaries = _.groupBy(summariesToRecreate, (summary) => summary.instanceId);
            const daySummariesPromises: (() => Promise<TimeTrackingDaySummary[]>)[] = [];
            for (const [instanceIdKey, summaries] of Object.entries(groupedDaySummaries)) {
                const instanceId = Number(instanceIdKey);
                const groupedByUser = _.groupBy(summaries, (summary) => summary.userId);
                for (const [userIdKey, userSummaries] of Object.entries(groupedByUser)) {
                    const userId = Number(userIdKey);
                    daySummariesPromises.push(async () => {
                        const instance = await this.instanceService.findByPk(instanceId);
                        const dates = userSummaries.map((summary) => moment.tz(summary.dateString, instance.getTimezone()).startOf('day'));
                        return this.createMissingSummariesForUser(userId, instance, dates);
                    });
                }
            }
            const createdDaySummaries = await allPromises(daySummariesPromises);
            summariesToProcess.push(...createdDaySummaries.flat());
        }

        const timezoneByInstanceId = await this.buildTimezoneByInstanceId(summariesToProcess.map((s) => s.instanceId));

        for (const summary of summariesToProcess) {
            summary.timeTrackingEntries.sort(this.sortEntriesTimeDescending);
            const timezone = timezoneByInstanceId.get(summary.instanceId)!;
            const lastEntry = summary.timeTrackingEntries[0];
            if (!lastEntry || lastEntry.type === TimeTrackingEntryType.END) {
                // should be closed already!!
                const workedHours = calculateWorkedHours(summary.timeTrackingEntries);
                this.updateUnderworkedIncidence(workedHours, summary);
                summariesToUpdate.push({
                    dateString: summary.dateString,
                    id: summary.id,
                    incidences: summary.incidences,
                    instanceId: summary.instanceId,
                    state: State.CLOSED,
                    workedHours,
                });
                continue;
            }
            const isAutoCloseTimeBeforeDayChange = summary.autoCloseTime && moment(summary.autoCloseTime).isBefore(summary.dayChange);
            if (isAutoCloseTimeBeforeDayChange) {
                this.processAutoClose(summary, timezone, entriesToCreate, summariesToUpdate, summariesToCorrect);
                continue;
            }
            if (!summary.timeTrackingEntries.some((entry) => isUserGeneratedSource(entry.source)) && !isAutoCloseTimeBeforeDayChange) {
                // something went wrong, we should have either a user entry or auto close time
                const policy = await this.getUserCurrentPolicy(summary.instanceId, summary.userId, summary.dateString);
                const { autoCloseTime, autoCloseEntryTime } = getAutoCloseData(policy, {
                    estimatedHours: summary.estimatedHours,
                    timeSlots: summary.timeSlots ?? null,
                    dateString: summary.dateString,
                    timezone,
                    allEntries: summary.timeTrackingEntries,
                });
                summary.autoCloseTime = moment(autoCloseTime).isBefore(summary.dayChange) ? autoCloseTime : summary.dayChange;
                summary.autoCloseEntryTime = moment(autoCloseEntryTime).isBefore(summary.dayChange) ? autoCloseEntryTime : summary.dayChange;
                this.processAutoClose(summary, timezone, entriesToCreate, summariesToUpdate, summariesToCorrect);
                continue;
            }
            const dayCloseEntry = await this.newDayCloseEntry(summary.instanceId, summary.userId, summary.dayChange, summary.id, lastEntry.siteId);
            if (!this.isDuplicateEntry(summary, dayCloseEntry)) {
                entriesToCreate.push(dayCloseEntry);
                const entriesForWorkedHours: Array<TimeTrackingEntry | TimeTrackingAutoCloseEntry> = [...summary.timeTrackingEntries, dayCloseEntry];
                const workedHours = calculateWorkedHours(entriesForWorkedHours);
                this.updateUnderworkedIncidence(workedHours, summary);
                summariesToUpdate.push({
                    dateString: summary.dateString,
                    id: summary.id,
                    incidences: summary.incidences,
                    instanceId: summary.instanceId,
                    state: State.CLOSED,
                    workedHours,
                });
            }
            const nextDay = moment(summary.dateString).add(1, 'day').format(ISO_DATE);
            const nextDayStart = moment(summary.dayChange).add(1, 'ms');
            let newDaySummary = await this.timeTrackingRepository.getDaySummary(summary.userId, nextDay);
            let autoCloseTime = summary.autoCloseTime && moment(summary.autoCloseTime).isAfter(summary.dayChange) ? summary.autoCloseTime : undefined;
            let autoCloseEntryTime = summary.autoCloseEntryTime ?? undefined;
            if (newDaySummary?.timeTrackingEntries.length && newDaySummary.autoCloseTime) {
                autoCloseTime = newDaySummary.autoCloseTime;
                autoCloseEntryTime = newDaySummary.autoCloseEntryTime;
            }
            if (!newDaySummary) {
                const instance = await this.instanceService.findByPk(summary.instanceId!);
                const missingDate = moment.tz(nextDay, instance.getTimezone()).startOf('day');
                const summaries = await this.createMissingSummariesForUser(summary.userId, instance, [missingDate]);
                newDaySummary = this.chooseDaySummary(summaries);
            }
            const dayStartEntry = await this.newDayStartEntry(
                newDaySummary.instanceId,
                newDaySummary.userId,
                nextDayStart.toDate(),
                newDaySummary.id,
                lastEntry.siteId,
            );
            if (!this.isDuplicateEntry(newDaySummary, dayStartEntry) && !this.isDuplicateEntry(newDaySummary, dayStartEntry)) {
                entriesToCreate.push(dayStartEntry);
                const entriesForWorkedHours: Array<TimeTrackingEntry | TimeTrackingAutoCloseEntry> = [
                    ...newDaySummary.timeTrackingEntries,
                    dayStartEntry,
                ].sort(this.sortEntriesTimeAscending);
                summariesToUpdate.push({
                    autoCloseEntryTime: autoCloseEntryTime,
                    autoCloseTime: autoCloseTime,
                    dateString: newDaySummary.dateString,
                    id: newDaySummary.id,
                    instanceId: newDaySummary.instanceId,
                    state: entriesForWorkedHours[entriesForWorkedHours.length - 1].type === TimeTrackingEntryType.END ? State.CLOSED : State.OPEN,
                    workedHours: calculateWorkedHours(entriesForWorkedHours),
                });
            }
        }
        if (summariesToCorrect.length) {
            await this.processCorrectiveDaySummaries(
                summariesToCorrect,
                entriesToCreate,
                entriesToDelete,
                summariesToUpdate,
                limit,
                timezoneByInstanceId,
            );
        }
        return await this.processAutoCloseEntries(entriesToCreate, summariesToUpdate, entriesToDelete);
    };

    private closeEntriesLeftOpen = async (
        date: Date,
        fromDate: Date,
        limit: number,
    ): Promise<{ summariesUpdated: number; autoCloseBatches: AutoCloseNotificationBatch[] }> => {
        const entriesToCreate: TimeTrackingAutoCloseEntry[] = [];
        const entriesToDelete: EntryDataToDelete[] = [];
        const summariesToUpdate: SummaryDataToUpdate[] = [];
        const summariesToCorrect: SummaryDataToCorrect[] = [];
        const summaries = await this.timeTrackingRepository.getDaySummariesForAutoClose(date, fromDate, limit);
        const timezoneByInstanceId = await this.buildTimezoneByInstanceId(summaries.map((s) => s.instanceId));
        summaries.forEach((summary) =>
            this.processAutoClose(summary, timezoneByInstanceId.get(summary.instanceId)!, entriesToCreate, summariesToUpdate, summariesToCorrect),
        );
        if (summariesToCorrect.length) {
            await this.processCorrectiveDaySummaries(
                summariesToCorrect,
                entriesToCreate,
                entriesToDelete,
                summariesToUpdate,
                limit,
                timezoneByInstanceId,
            );
        }
        return await this.processAutoCloseEntries(entriesToCreate, summariesToUpdate, entriesToDelete);
    };

    private buildTimezoneByInstanceId = async (instanceIds: number[]): Promise<Map<number, string>> => {
        const uniqueIds = [...new Set(instanceIds)];
        const instances = await allPromises(uniqueIds.map((id) => () => this.instanceService.findByPk(id)));
        return new Map(instances.map((instance) => [instance.getId(), instance.getTimezone()]));
    };

    private processCorrectiveDaySummaries = async (
        summariesToCorrect: SummaryDataToCorrect[],
        entriesToCreate: TimeTrackingAutoCloseEntry[],
        entriesToDelete: EntryDataToDelete[],
        summariesToUpdate: SummaryDataToUpdate[],
        limit: number,
        timezoneByInstanceId: Map<number, string>,
    ) => {
        const gridFiltersInterface: GridFilterRequest = {
            cells: summariesToCorrect.flatMap((summary) => [
                { date: summary.dateString, userId: summary.userId },
                { date: moment(summary.dateString).subtract(1, 'day').format(ISO_DATE), userId: summary.userId },
            ]),
        };
        const daySummaries = await this.timeTrackingRepository.getDaySummariesToCorrectByGridFilters(gridFiltersInterface, limit * 2);
        const daySummariesForUserAndDateMap = new Map<number, Map<string, TimeTrackingDaySummary>>();
        for (const summary of daySummaries) {
            const userAndDateMap = daySummariesForUserAndDateMap.get(summary.userId) || new Map<string, TimeTrackingDaySummary>();
            userAndDateMap.set(summary.dateString, summary);
            daySummariesForUserAndDateMap.set(summary.userId, userAndDateMap);
        }
        for (const summaryToCorrect of summariesToCorrect) {
            const userAndDateMap = daySummariesForUserAndDateMap.get(summaryToCorrect.userId) || new Map<string, TimeTrackingDaySummary>();
            const currentDateString = summaryToCorrect.dateString;
            const previousDateString = moment(currentDateString).subtract(1, 'day').format(ISO_DATE);
            const currentSummary = userAndDateMap.get(currentDateString);
            const previousSummary = userAndDateMap.get(previousDateString);
            if (!(currentSummary && previousSummary)) continue;
            const timezone =
                timezoneByInstanceId.get(previousSummary.instanceId) ??
                (await this.instanceService.findByPk(previousSummary.instanceId)).getTimezone();
            timezoneByInstanceId.set(previousSummary.instanceId, timezone);
            this.processAutoCloseCorrective(previousSummary, currentSummary, timezone, entriesToCreate, entriesToDelete, summariesToUpdate);
        }
    };

    private processAutoCloseCorrective = (
        previousSummary: TimeTrackingDaySummary,
        currentSummary: TimeTrackingDaySummary,
        timezone: string,
        entriesToCreate: TimeTrackingAutoCloseEntry[],
        entriesToDelete: EntryDataToDelete[],
        summariesToUpdate: SummaryDataToUpdate[],
    ) => {
        // Defensive checks, should not happen
        if (
            !currentSummary.autoCloseEntryTime ||
            !currentSummary.autoCloseTime ||
            currentSummary.nightShift ||
            !currentSummary.timeTrackingEntries.length ||
            !previousSummary.timeTrackingEntries.length
        )
            return;
        // ensure this is the "bridge" case
        if (!moment(currentSummary.autoCloseEntryTime).isSameOrBefore(moment(currentSummary.date))) return;
        if (!moment(currentSummary.autoCloseTime).isAfter(moment(currentSummary.date))) return;
        // This edge case can happen with admin/manual imputations (00:00), not with real clock-ins.
        const autoCloseEntryTime = moment(currentSummary.autoCloseEntryTime).isSame(moment(currentSummary.date))
            ? previousSummary.dayChange
            : currentSummary.autoCloseEntryTime;

        const entriesToDeleteSet = new Set<number>();
        const entriesToDeleteArray = [];

        const entriesToDeletePreviousDay = previousSummary.timeTrackingEntries.filter(
            (e) => isSourceDayChange(e.source) && e.type === TimeTrackingEntryType.END,
        );
        const entriesToDeleteCurrentDay = currentSummary.timeTrackingEntries.filter(
            (e) => isSourceDayChange(e.source) && e.type === TimeTrackingEntryType.START,
        );

        entriesToDeletePreviousDay.forEach((e) => entriesToDeleteSet.add(e.id!));
        entriesToDeleteArray.push(
            ...entriesToDeletePreviousDay.map((e) => ({
                id: e.id,
                instanceId: previousSummary.instanceId,
            })),
        );
        entriesToDeleteCurrentDay.forEach((e) => entriesToDeleteSet.add(e.id!));
        entriesToDeleteArray.push(
            ...entriesToDeleteCurrentDay.map((e) => ({
                id: e.id,
                instanceId: currentSummary.instanceId,
            })),
        );

        previousSummary.timeTrackingEntries = previousSummary.timeTrackingEntries.filter((e) => !entriesToDeleteSet.has(e.id!));
        currentSummary.timeTrackingEntries = currentSummary.timeTrackingEntries.filter((e) => !entriesToDeleteSet.has(e.id!));

        const autoCloseFallback = deriveAutoCloseFallback(previousSummary, timezone);
        const autoCloseEntry = this.newAutoCloseEntry(
            previousSummary.userId,
            autoCloseEntryTime,
            previousSummary.id,
            previousSummary.instanceId,
            true, // overrideDesertEagleState for corrective
            autoCloseFallback,
        );

        const previousEntriesForWorkedHours: Array<TimeTrackingEntry | TimeTrackingAutoCloseEntry> = [...previousSummary.timeTrackingEntries];
        if (!this.isDuplicateEntry(previousSummary, autoCloseEntry)) {
            entriesToCreate.push(autoCloseEntry);
            previousEntriesForWorkedHours.push(autoCloseEntry);
        }

        const previousWorkedHours = calculateWorkedHours(previousEntriesForWorkedHours);
        this.updateUnderworkedIncidence(previousWorkedHours, previousSummary);

        const currentWorkedHours = calculateWorkedHours(currentSummary.timeTrackingEntries);
        this.updateUnderworkedIncidence(currentWorkedHours, currentSummary);

        summariesToUpdate.push({
            autoCloseEntryTime: previousSummary.autoCloseEntryTime,
            autoCloseTime: previousSummary.autoCloseTime,
            dateString: previousSummary.dateString,
            id: previousSummary.id,
            incidences: previousSummary.incidences,
            instanceId: previousSummary.instanceId,
            state: State.CLOSED,
            workedHours: previousWorkedHours,
        });
        summariesToUpdate.push({
            autoCloseEntryTime: currentSummary.autoCloseEntryTime,
            autoCloseTime: currentSummary.autoCloseTime,
            dateString: currentSummary.dateString,
            id: currentSummary.id,
            incidences: currentSummary.incidences,
            instanceId: currentSummary.instanceId,
            state: State.CLOSED,
            workedHours: currentWorkedHours,
        });
        entriesToDelete.push(...entriesToDeleteArray);
    };

    private processAutoCloseEntries = async (
        entriesToCreate: TimeTrackingAutoCloseEntry[],
        summariesToUpdate: SummaryDataToUpdate[],
        entriesToDelete: EntryDataToDelete[],
    ): Promise<{ summariesUpdated: number; autoCloseBatches: AutoCloseNotificationBatch[] }> => {
        let summariesToUpdateCount = 0;
        const autoCloseBatches: AutoCloseNotificationBatch[] = [];
        if (entriesToCreate.length || summariesToUpdate.length || entriesToDelete.length) {
            const groupedEntriesToCreate = this.getMappedGroupByInstanceId(entriesToCreate);
            const groupedSummariesToUpdate = this.getMappedGroupByInstanceId(summariesToUpdate);
            const groupedEntriesToDelete = this.getMappedGroupByInstanceId(entriesToDelete);
            const instanceIds = Array.from(
                new Set<number>([...groupedEntriesToCreate.keys(), ...groupedSummariesToUpdate.keys(), ...groupedEntriesToDelete.keys()]),
            );
            const mappedTimeTrackingDesertEagleEnabledFeatureFlags = await this.featureFlagService.getMappedBooleanFeatureFlagsByInstanceIds(
                FeatureFlagCode.TIME_TRACKING_DESERT_EAGLE_ENABLED,
                instanceIds,
            );
            const globalTimeTrackingDesertEagleEnabledFeatureFlagValue = await this.featureFlagService.getGlobalBooleanFeatureFlagValue(
                FeatureFlagCode.TIME_TRACKING_DESERT_EAGLE_ENABLED,
            );

            const summaryDateStringMap = new Map<number, string>();
            for (const summary of summariesToUpdate) {
                summaryDateStringMap.set(summary.id, summary.dateString);
            }

            const pendingScheduleParams: Array<{ dateStrings: string[]; instanceId: number; userIds: number[] }> = [];

            for (const instanceId of instanceIds) {
                const summariesForInstance = groupedSummariesToUpdate.get(instanceId) || [];
                const entriesForInstance = groupedEntriesToCreate.get(instanceId) || [];
                const entriesToDeleteForInstance = groupedEntriesToDelete.get(instanceId) || [];
                const timeTrackingDesertEagleEnabledFeatureFlag =
                    mappedTimeTrackingDesertEagleEnabledFeatureFlags.get(instanceId) || globalTimeTrackingDesertEagleEnabledFeatureFlagValue;
                if (entriesForInstance.length && timeTrackingDesertEagleEnabledFeatureFlag) {
                    try {
                        const autoCloseResults = await this.timeTrackingEntriesRepository.processAutoCloseEntries(entriesForInstance);
                        for (const result of autoCloseResults) {
                            if (result.status === AutoCloseEntryStatus.AUTO_CLOSE_ENTRY_STATUS_CREATED) {
                                const idx = result.index;
                                const autoCloseEntry = entriesForInstance[idx];
                                if (autoCloseEntry) {
                                    autoCloseEntry.desertEagleEntryId = result.entryId;
                                }
                            }
                        }
                    } catch (error) {
                        this.logger.error(error, `Error creating bulk entries for instance ${instanceId}`);
                    }
                }
                if (entriesForInstance.length || summariesForInstance.length || entriesToDeleteForInstance.length) {
                    try {
                        await this.timeTrackingRepository.getTransaction(async () => {
                            if (entriesToDeleteForInstance.length) {
                                await this.timeTrackingRepository.bulkDeleteEntries(
                                    instanceId,
                                    entriesToDeleteForInstance.map((entry) => entry.id),
                                );
                            }
                            if (entriesForInstance.length) {
                                await this.timeTrackingRepository.createBulkEntries(entriesForInstance);
                            }
                            if (summariesForInstance.length) {
                                await this.timeTrackingRepository.updateDaySummaries(summariesForInstance);
                            }
                        });

                        summariesToUpdateCount += summariesForInstance.length;

                        const endEntriesForInstance = entriesForInstance.filter((e) => e.type === TimeTrackingEntryType.END);
                        if (endEntriesForInstance.length > 0) {
                            const dateStrings = [
                                ...new Set(endEntriesForInstance.map((e) => summaryDateStringMap.get(e.summaryId!)).filter(Boolean)),
                            ] as string[];
                            const userIds = [...new Set(endEntriesForInstance.map((e) => e.userId))];
                            pendingScheduleParams.push({ dateStrings, instanceId, userIds });
                        }

                        // Stage AUTO_CLOSE incidence-notification batch. The caller dispatches each
                        // batch through the orchestrator after this method returns, so a notification
                        // failure cannot undo the per-instance auto-close TX.
                        autoCloseBatches.push({ instanceId, autoCloseEntries: entriesForInstance, summaryDateStringMap });
                    } catch (error) {
                        this.logger.error({ error, instanceId }, `Error creating bulk entries for instance ${instanceId}`);
                    }
                }
            }

            if (pendingScheduleParams.length > 0) {
                const delay = DateTime.now().plus({ minutes: CATEGORIZED_HOURS_SCHEDULE_DELAY_MINUTES }).toJSDate();
                await this.scheduleCategorizedHoursPort.bulkSchedule(
                    pendingScheduleParams.map((params) => ({ payload: params, type: CategorizedHoursActionType.CALCULATE_CATEGORIZED_HOURS })),
                    delay,
                );
            }
        }
        return { summariesUpdated: summariesToUpdateCount, autoCloseBatches };
    };

    private processAutoClose = (
        summary: TimeTrackingDaySummary,
        timezone: string,
        entriesToCreate: TimeTrackingAutoCloseEntry[],
        summariesToUpdate: SummaryDataToUpdate[],
        summariesToCorrect: SummaryDataToCorrect[],
    ) => {
        // defensive check, should not happen
        if (summary.state === State.CLOSED) return;
        if (this.hasToCorrectDaySummaries(summary)) {
            summariesToCorrect.push({
                autoCloseEntryTime: summary.autoCloseEntryTime,
                autoCloseTime: summary.autoCloseTime,
                dateString: summary.dateString,
                instanceId: summary.instanceId,
                summaryId: summary.id,
                userId: summary.userId,
            });
            return;
        }
        const lastEntry = summary.timeTrackingEntries.sort(this.sortEntriesTimeDescending)[0];
        const autoCloseFallback = deriveAutoCloseFallback(summary, timezone);
        const autoCloseEntry = this.newAutoCloseEntry(
            summary.userId,
            summary.autoCloseEntryTime!,
            summary.id,
            summary.instanceId,
            false,
            autoCloseFallback,
        );
        const entriesForWorkedHours: Array<TimeTrackingEntry | TimeTrackingAutoCloseEntry> = [...summary.timeTrackingEntries];
        if (
            lastEntry &&
            lastEntry.type != TimeTrackingEntryType.END &&
            !this.isDuplicateEntry(summary, autoCloseEntry) &&
            (summary.nightShift || moment(autoCloseEntry.time).isBefore(summary.dayChange))
        ) {
            entriesToCreate.push(autoCloseEntry);
            entriesForWorkedHours.push(autoCloseEntry);
        }
        const workedHours = calculateWorkedHours(entriesForWorkedHours);
        this.updateUnderworkedIncidence(workedHours, summary);
        summariesToUpdate.push({
            autoCloseEntryTime: summary.autoCloseEntryTime,
            autoCloseTime: summary.autoCloseTime,
            dateString: summary.dateString,
            id: summary.id,
            incidences: summary.incidences,
            instanceId: summary.instanceId,
            state: State.CLOSED,
            workedHours,
        });
    };

    private hasToCorrectDaySummaries(summary: TimeTrackingDaySummary): boolean {
        return (
            !summary.nightShift &&
            !!summary.autoCloseEntryTime &&
            moment(summary.autoCloseEntryTime).isSameOrBefore(summary.date) &&
            !!summary.autoCloseTime &&
            moment(summary.autoCloseTime).isAfter(summary.date)
        );
    }

    private getMappedGroupByInstanceId = <T extends { instanceId?: number }>(items: T[]): Map<number, T[]> => {
        const map = new Map<number, T[]>();
        items.forEach((item) => {
            if (item.instanceId !== undefined) {
                const existing = map.get(item.instanceId) || [];
                existing.push(item);
                map.set(item.instanceId, existing);
            }
        });
        return map;
    };

    private updateUnderworkedIncidence(workedHours: number, summary: TimeTrackingDaySummary) {
        const shouldHaveUnderworkedIncidence =
            this.checkIfUnderworked(workedHours, summary.estimatedHours) && !summary.incidences.includes(Incidence.ABSENT);
        const hasUnderworkedIncidence = summary.incidences.includes(Incidence.UNDERWORKED);
        if (shouldHaveUnderworkedIncidence && !hasUnderworkedIncidence) {
            summary.incidences.push(Incidence.UNDERWORKED);
        } else if (!shouldHaveUnderworkedIncidence && hasUnderworkedIncidence) {
            summary.incidences = summary.incidences.filter((incidence) => incidence !== Incidence.UNDERWORKED);
        }
    }

    private isDuplicateEntry(newDaySummary: TimeTrackingDaySummary, dayCloseEntry: TimeTrackingEntryPrototype) {
        return newDaySummary.timeTrackingEntries.some(
            (entry) => entry.type == dayCloseEntry.type && entry.time.getTime() == dayCloseEntry.time.getTime(),
        );
    }

    private sortEntriesTimeDescending(a: { time: Date }, b: { time: Date }): number {
        return b.time.getTime() - a.time.getTime();
    }

    private sortEntriesTimeAscending(a: { time: Date }, b: { time: Date }): number {
        return a.time.getTime() - b.time.getTime();
    }

    private isStartEntryByUser(entry: TimeTrackingEntryPrototype): unknown {
        return entry.type === TimeTrackingEntryType.START && isUserGeneratedSource(entry.source);
    }

    private isRequestHalfDay(request: TimeOffRequest, stringDate: string) {
        return (
            (request.from.date === stringDate && request.from.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HALF_DAY) ||
            (request.to.date === stringDate && request.to.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HALF_DAY)
        );
    }

    private createMissingSummariesForUser = async (userId: number, instance: Instance, missingDates: Moment[]): Promise<TimeTrackingDaySummary[]> => {
        const timezone = instance.getTimezone();
        const fromDate = missingDates[0].clone().startOf('day');
        const toDate = missingDates[missingDates.length - 1].clone().endOf('day');
        const entriesFromDate = fromDate.clone().subtract(1, 'day').startOf('day');
        const entriesToDate = toDate.clone().add(1, 'day').endOf('day');
        const { rows: entries } = await this.listEntries(userId, entriesFromDate.toDate(), entriesToDate.toDate(), { limit: 1000, offset: 0 });
        const siteIds = new Set(entries.filter((siteId) => !!siteId).map((entry) => entry.siteId!));
        const fromDateString = fromDate.format(ISO_DATE);
        const toDateString = toDate.format(ISO_DATE);
        const [schedules, sites, allHolidays, timeOffResponse, shiftAssignedDays] = (await allPromises<
            AssignedWorkSchedule[] | Site[] | Holiday[] | { requests: TimeOffRequest[] } | ShiftAssignedDay[]
        >(
            [
                () => this.workScheduleService.getAssignedWorkSchedulesByUser(userId, instance.getId(), fromDateString, toDateString),
                () => this.siteService.getByIds(Array.from(siteIds), instance.getId(), true),
                () => this.regionService.getHolidays(userId, { fromDate: fromDateString, toDate: toDateString }),
                () =>
                    this.timeOffListRequestsService.listRequests(
                        { admin: false, id: userId, instanceId: instance.getId() } as TimeOffUser,
                        {
                            requestStates: [TIME_OFF_REVIEW_STATES.APPROVED],
                            requestedFromDate: fromDateString,
                            requestedToDate: toDateString,
                            role: TIME_OFF_REQUEST_ROLES.COLLABORATOR,
                        },
                        { includePolicy: true },
                    ),
                () => this.shiftAssignedDayService.getShiftAssignedDaysByUser(userId, instance.getId(), fromDateString, toDateString),
            ],
            5,
        )) as [AssignedWorkSchedule[], Site[], Holiday[], { requests: TimeOffRequest[] }, ShiftAssignedDay[]];
        const { rows: policiesAssociations } = await this.timeTrackingPoliciesService.listPolicyAssociations(
            { fromDate: fromDate.format(ISO_DATE), instanceId: instance.getId(), toDate: toDate.format(ISO_DATE), userIds: [userId] },
            {},
        );
        const policiesHistoryLatenessTolerance = await this.getTimeTrackingPolicyHistoryLatenessTolerance(userId, fromDateString, toDateString);
        const setting = await this.timeTrackingSettingService.getByInstanceId(instance.getId());

        schedules.sort((a, b) => moment(b.startDate).valueOf() - moment(a.startDate).valueOf()); // desc
        const allTimeOffRequests = timeOffResponse.requests;
        const groupedEntries = _.groupBy(entries, (entry) => moment.tz(entry.time, timezone).format(ISO_DATE));
        const missingSummariesAttributes = missingDates.map((date) => {
            date.tz(timezone);

            const { assignment, entries, holidays, timeOffRequests, hours, scheduleData, latenessTolerance, createPolicyHistory } =
                this.getDaySummaryDataForDay(
                    date,
                    instance,
                    timezone,
                    groupedEntries,
                    allTimeOffRequests,
                    allHolidays,
                    schedules,
                    shiftAssignedDays,
                    policiesAssociations,
                    policiesHistoryLatenessTolerance,
                );

            const incidences = this.shouldNotComputeIncidences(timeOffRequests)
                ? []
                : this.computeMultipleEntriesIncidences(
                      hours.estimated,
                      hours.worked,
                      entries,
                      timezone,
                      scheduleData.timeSlots ?? [],
                      sites,
                      latenessTolerance,
                  );
            const { autoCloseTime, autoCloseEntryTime, autoCloseMode } = getAutoCloseData(assignment?.policy, {
                estimatedHours: hours.estimated,
                timeSlots: scheduleData.timeSlots ?? null,
                dateString: date.format(ISO_DATE),
                timezone,
                allEntries: entries,
            });
            return {
                autoCloseEntryTime,
                autoCloseMode,
                autoCloseTime,
                createPolicyHistory: createPolicyHistory,
                date: date.clone().startOf('day').toDate(),
                estimatedHours: hours.estimated,
                holidays,
                incidences,
                instanceId: instance.getId(),
                isWorkday: scheduleData.isWorkday,
                latenessTolerance: latenessTolerance,
                nightShift: this.getNightShiftValue(assignment, setting),
                scheduleSource: scheduleData.scheduleSource,
                scheduledHours: hours.scheduled,
                shiftAssignedDayId: scheduleData.scheduleSource === DaySummaryScheduleSource.SHIFT ? scheduleData.shiftAssignedDay?.id : undefined,
                timeOffHours: hours.timeOff ?? null,
                timeOffRequests,
                timeSlots: scheduleData.timeSlots,
                timeTrackingEntries: entries,
                userId,
                weekday: scheduleData.scheduleDay?.dayOfWeek ?? toWorkday(date.toDate(), instance.getTimezone()),
                workScheduleId: scheduleData.scheduleSource === DaySummaryScheduleSource.WORK_SCHEDULE ? scheduleData.schedule?.id : undefined,
                workedHours: hours.worked,
            };
        });
        return await this.timeTrackingRepository.createDaySummaries(missingSummariesAttributes, instance);
    };

    private async getUserCurrentPolicy(instanceId: number, userId: number, dateString: string): Promise<TimeTrackingPolicy | undefined> {
        const { rows: policiesAssociations } = await this.timeTrackingPoliciesService.listPolicyAssociations(
            { fromDate: dateString, instanceId, toDate: dateString, userIds: [userId] },
            {},
        );
        const assignment = policiesAssociations.find(
            (association) => association.fromDate <= dateString && (!association.toDate || association.toDate >= dateString),
        );
        return assignment?.policy;
    }

    private getNightShiftValue(assignment: TimeTrackingPolicyAssociation | undefined, setting: TimeTrackingSetting): boolean {
        return assignment?.policy
            ? assignment?.policy.nightShift
            : (setting.nightShiftAssignment as NightShiftAssignment) === NightShiftAssignment.SCHEDULE_INIT;
    }

    private getDaySummaryDataForDay(
        date: Moment,
        instance: Instance,
        timezone: string,
        groupedEntries: _.Dictionary<TimeTrackingEntry[]>,
        allTimeOffRequests: TimeOffRequest[],
        allHolidays: Holiday[],
        schedules: AssignedWorkSchedule[],
        shiftAssignedDays: ShiftAssignedDay[],
        policiesAssociations: TimeTrackingPolicyAssociation[],
        policiesHistoryLatenessTolerance: {
            dateString: string;
            latenessTolerance: number | null;
        }[],
    ) {
        const stringDate = date.format(ISO_DATE);
        const holidays = this.getDaySummaryHolidaysForDay(allHolidays, stringDate);
        const isHoliday = holidays.length > 0;
        const scheduleData = this.getDaySummaryScheduleData(date, instance, timezone, isHoliday, shiftAssignedDays, schedules);
        const assignment = policiesAssociations.find(
            (association) => association.fromDate <= stringDate && (!association.toDate || association.toDate >= stringDate),
        );
        const entries = this.getEntriesForDay(date, groupedEntries, assignment);

        const scheduledHours = scheduleData.timeSlots ? this.getScheduledHoursByTimeSlot(scheduleData.timeSlots) : 0;
        const timeOffRequests = this.getDaySummaryTimeOffForDay(allTimeOffRequests, stringDate, scheduleData.isWorkday, scheduledHours);
        const timeOffHours = timeOffRequests
            .filter((request) => request.activityType !== TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES.WORK)
            .reduce((acc, request) => acc + (request.hours ?? 0), 0);

        const estimatedHours = this.getEstimatedHours(scheduledHours, timeOffHours, isHoliday, scheduleData);

        const workedHours = calculateWorkedHours(entries);
        const hours = new TimeTrackingDaySummaryHours({
            estimated: estimatedHours,
            scheduled: scheduledHours,
            timeOff: timeOffHours,
            worked: workedHours,
        });

        const { latenessTolerance, createPolicyHistory } = this.getLatenessToleranceAndCreatePolicy(
            policiesHistoryLatenessTolerance,
            stringDate,
            date,
            timezone,
            assignment,
        );

        return {
            assignment,
            createPolicyHistory,
            entries,
            holidays,
            hours,
            latenessTolerance,
            scheduleData,
            timeOffRequests,
        };
    }

    private getEstimatedHours(scheduledHours: number, timeOffHours: number, isHoliday: boolean, scheduleData: TimeTrackingDaySummaryScheduleData) {
        if (!isHoliday || scheduleData.scheduleSource === DaySummaryScheduleSource.SHIFT) {
            return Math.max(scheduledHours - timeOffHours, 0);
        }
        return 0;
    }

    private getDaySummaryScheduleData(
        date: Moment,
        instance: Instance,
        timezone: string,
        isHoliday: boolean,
        shiftAssignedDays: ShiftAssignedDay[],
        schedules: AssignedWorkSchedule[],
    ) {
        const stringDate = date.format(ISO_DATE);
        const shiftAssignedDay = shiftAssignedDays.find((shiftAssignedDay) => shiftAssignedDay.dateString === stringDate);
        const schedule = schedules.find(
            (schedule) =>
                date.isSameOrAfter(moment.tz(schedule.startDate, timezone), 'day') &&
                (!schedule.endDate || date.isSameOrBefore(moment.tz(schedule.endDate, timezone), 'day')),
        );
        const scheduleDay = this.findScheduledDay(schedule, date.toDate(), instance);
        const scheduleSource = this.determineScheduleSource(shiftAssignedDay, schedule);
        const isWorkday = this.determineIsWorkday(isHoliday, shiftAssignedDay, scheduleDay);
        const timeSlots = this.getDaySummaryTimeSlots(scheduleSource, shiftAssignedDay, scheduleDay);

        return new TimeTrackingDaySummaryScheduleData({
            isWorkday,
            schedule,
            scheduleDay,
            scheduleSource,
            shiftAssignedDay,
            timeSlots,
        });
    }

    private getLatenessToleranceAndCreatePolicy(
        policiesHistoryLatenessTolerance: {
            dateString: string;
            latenessTolerance: number | null;
        }[],
        stringDate: string,
        date: Moment,
        timezone: string,
        assignment: TimeTrackingPolicyAssociation | undefined,
    ) {
        const matchingHistoryPolicy = policiesHistoryLatenessTolerance.find((policy) => policy.dateString === stringDate);
        if (matchingHistoryPolicy) {
            return {
                createPolicyHistory: true,
                latenessTolerance: matchingHistoryPolicy.latenessTolerance,
            };
        } else {
            if (assignment) {
                const policyStartDateMoment = moment.tz(assignment?.policy?.generatePolicyHistoryFromDate, timezone).startOf('day');
                const summaryDateMoment = date.clone().startOf('day');
                if (policyStartDateMoment.isValid() && summaryDateMoment.isSameOrAfter(policyStartDateMoment)) {
                    return {
                        createPolicyHistory: true,
                        latenessTolerance: assignment?.policy?.latenessTolerance,
                    };
                }
            }
        }
        return {
            createPolicyHistory: false,
            latenessTolerance: LATENESS_IN_MINUTES,
        };
    }

    private determineScheduleSource(shiftAssignedDay: ShiftAssignedDay | undefined, schedule: AssignedWorkSchedule | undefined) {
        return shiftAssignedDay
            ? DaySummaryScheduleSource.SHIFT
            : schedule
              ? DaySummaryScheduleSource.WORK_SCHEDULE
              : DaySummaryScheduleSource.NO_SCHEDULE;
    }

    private getDaySummaryHolidaysForDay(allHolidays: Holiday[], stringDate: string) {
        return allHolidays
            .filter((holiday) => holiday.getId() && stringDate === holiday.getDate())
            .map((holiday) => ({ holidayId: holiday.getId()!, holidayName: holiday.getName() }) as TimeTrackingDaySummaryHoliday);
    }

    private getDaySummaryTimeOffForDay(allTimeOffRequests: TimeOffRequest[], stringDate: string, isWorkday: boolean, scheduledHours: number) {
        return allTimeOffRequests
            .filter(
                (request) =>
                    request.from.date <= stringDate &&
                    stringDate <= request.to.date &&
                    (request.policy?.countingMethod === TIME_OFF_POLICY_COUNTING_METHODS.CALENDAR_DAYS ||
                        (request.policy?.countingMethod === TIME_OFF_POLICY_COUNTING_METHODS.BUSINESS_DAYS && isWorkday)),
            )
            .map(
                (request) =>
                    ({
                        activityType: request.policyType?.activityType,
                        consumptionType: this.getConsumptionTypeForDate(request, stringDate),
                        fromDate: request.from.date ?? null,
                        fromTime: request.from.time ?? null,
                        hours: this.getTimeOffHours(stringDate, request, scheduledHours),
                        timeOffPolicyTypeIcon: request.policyType?.icon,
                        timeOffPolicyTypeName: request.policyType?.name,
                        timeOffRequestId: request.id,
                        toDate: request.to.date ?? null,
                        toTime: request.to.time ?? null,
                    }) as TimeTrackingDaySummaryTimeOff,
            );
    }

    private getTimeOffHours(stringDate: string, request: TimeOffRequest, scheduledHours: number) {
        if (this.isTimeOffRequestHours(request)) {
            return request.amount;
        }
        return this.isRequestHalfDay(request, stringDate) ? scheduledHours / 2 : scheduledHours;
    }

    private getEntriesForDay(
        date: Moment,
        groupedEntries: _.Dictionary<TimeTrackingEntry[]>,
        policyAssigned: TimeTrackingPolicyAssociation | undefined,
    ) {
        const stringDate = date.format(ISO_DATE);
        const entries = groupedEntries[stringDate] ?? [];
        entries.sort(this.sortEntriesTimeAscending);

        if (entries.length) {
            const firstEntry = entries[0];
            if (firstEntry.type === TimeTrackingEntryType.END && policyAssigned?.policy?.nightShift) {
                const previousDayEntries = groupedEntries[date.clone().subtract(1, 'day').format(ISO_DATE)];
                previousDayEntries?.sort(this.sortEntriesTimeDescending);
                if (previousDayEntries?.length && previousDayEntries[previousDayEntries.length - 1].type === TimeTrackingEntryType.START) {
                    previousDayEntries.push(firstEntry);
                }
                entries.shift();
            }
        }
        if (entries.length) {
            const lastEntry = entries[entries.length - 1];
            if (lastEntry.type === TimeTrackingEntryType.START && policyAssigned?.policy?.nightShift) {
                const nextDayEntries = groupedEntries[date.clone().add(1, 'day').format(ISO_DATE)];
                nextDayEntries?.sort(this.sortEntriesTimeAscending);
                if (nextDayEntries?.length && nextDayEntries[0].type === TimeTrackingEntryType.END) {
                    entries.push(nextDayEntries[0]);
                }
                nextDayEntries?.shift();
            }
        }
        return entries;
    }

    private computeMultipleEntriesIncidences(
        estimatedHours: number,
        workedHours: number,
        entries: TimeTrackingEntryPrototype[],
        timezone: string,
        timeSlots: TimeSlot[],
        sites: Site[],
        latenessTolerance?: number | null,
    ) {
        const incidences: Incidence[] = [];
        // We are marking future days as absent, as summary would not get updated unless a new entry comes, leaving day marked as not absent.
        if (estimatedHours && !entries.some((entry) => isUserGeneratedSource(entry.source) && entry.type === TimeTrackingEntryType.START)) {
            incidences.push(Incidence.ABSENT);
            return incidences;
        }
        if (this.checkIfUnderworked(workedHours, estimatedHours)) {
            incidences.push(Incidence.UNDERWORKED);
        }

        if (estimatedHours && this.checkLateArrival(entries, timezone, timeSlots, latenessTolerance)) {
            incidences.push(Incidence.LATE);
        }
        entries
            .filter((entry) => isUserGeneratedSource(entry.source))
            .forEach((entry) => {
                const site = sites.find((site) => site.getId() === entry.siteId);
                if (site && hasLocationIncidence(entry, site)) {
                    incidences.push(Incidence.LOCATION_INCIDENCE);
                }
                if (!incidences.includes(Incidence.FACIAL_RECOGNITION_SKIPPED) && entry.facialRecognitionSkipped) {
                    incidences.push(Incidence.FACIAL_RECOGNITION_SKIPPED);
                }
            });
        return incidences;
    }

    private checkIfUnderworked(workedHours: number, estimatedHours: number) {
        return workedHours < estimatedHours - UNDERWORKED_THRESHOLD;
    }

    private findScheduledDay(schedule: WorkSchedule | undefined, currentDate: Date, instance: Instance) {
        return schedule?.scheduledDays.find((day) => day.dayOfWeek === toWorkday(currentDate, instance.getTimezone()));
    }

    private newAutoCloseEntry(
        userId: number,
        time: Date,
        summaryId: number,
        instanceId: number,
        overrideDesertEagleState = false,
        autoCloseFallback: AutoCloseFallback | null = null,
    ): TimeTrackingAutoCloseEntry {
        return {
            autoCloseFallback,
            instanceId,
            overrideDesertEagleState,
            source: TimeTrackingEntrySource.AUTO_CLOSE,
            summaryId,
            time,
            type: TimeTrackingEntryType.END,
            userId,
        };
    }

    private async newDayChangeEntry(
        instanceId: number,
        userId: number,
        time: Date,
        summaryId: number,
        type: TimeTrackingEntryType,
        siteId?: number,
    ): Promise<TimeTrackingAutoCloseEntry> {
        if (siteId) {
            const site = await this.siteService.getById(siteId, instanceId, true);
            return {
                instanceId,
                location: {
                    latitude: site.getLocation().latitude,
                    longitude: site.getLocation().longitude,
                },
                overrideDesertEagleState: false,
                siteId,
                source: TimeTrackingEntrySource.DAY_CHANGE,
                summaryId,
                time,
                type,
                userId,
            };
        }
        return {
            instanceId,
            overrideDesertEagleState: false,
            siteId,
            source: TimeTrackingEntrySource.DAY_CHANGE,
            summaryId,
            time,
            type,
            userId,
        };
    }

    private async newDayStartEntry(
        instanceId: number,
        userId: number,
        time: Date,
        summaryId: number,
        siteId?: number,
    ): Promise<TimeTrackingAutoCloseEntry> {
        return await this.newDayChangeEntry(instanceId, userId, time, summaryId, TimeTrackingEntryType.START, siteId);
    }

    private async newDayCloseEntry(
        instanceId: number,
        userId: number,
        time: Date,
        summaryId: number,
        siteId?: number,
    ): Promise<TimeTrackingAutoCloseEntry> {
        return await this.newDayChangeEntry(instanceId, userId, time, summaryId, TimeTrackingEntryType.END, siteId);
    }

    private getDaySummaryTimeSlots(
        scheduleSource: DaySummaryScheduleSource,
        shiftAssignedDay: ShiftAssignedDay | undefined,
        scheduleDay: ScheduledDays | undefined,
    ) {
        if (scheduleSource === DaySummaryScheduleSource.NO_SCHEDULE) {
            return undefined;
        } else if (scheduleSource === DaySummaryScheduleSource.SHIFT) {
            return shiftAssignedDay ? this.shiftAssignedDayService.getTimeSlotFromShiftAssignedDay(shiftAssignedDay) : undefined;
        } else {
            return scheduleDay ? scheduleDay.timeSlots : undefined;
        }
    }

    private getScheduledHoursByTimeSlot(timeSlots: TimeSlot[]) {
        return timeSlots.map((slot) => this.calculateDuration(slot)).reduce((a, b) => a + b, 0);
    }

    private determineIsWorkday(
        hasHolidays: boolean,
        shiftAssignedDay: ShiftAssignedDay | undefined,
        scheduleDay: ScheduledDays | undefined,
    ): boolean {
        if (shiftAssignedDay) {
            return !!shiftAssignedDay.isWorkday;
        }
        if (!hasHolidays) {
            return !!scheduleDay;
        }
        return false;
    }

    private calculateDuration(slot: TimeSlot) {
        const start = moment(slot.startTime, TIME_FORMAT);
        const end = moment(slot.endTime, TIME_FORMAT);
        //If the end time is before the start time, it means it goes to the next day (night shift)
        if (end.isBefore(start)) {
            end.add(1, 'day');
        }
        return end.diff(start, 'minutes') / MINUTES_IN_HOUR;
    }

    // TODO add policies for lateness
    private checkLateArrival(
        entriesForDate: TimeTrackingEntryPrototype[],
        timezone: string,
        timeSlots: TimeSlot[],
        latenessTolerance?: number | null,
    ) {
        if (latenessTolerance === null) {
            return false;
        }
        let wasLate = false;
        const startTime = entriesForDate
            .filter(this.isStartEntryByUser)
            .map((e) => moment(e.time))
            .sort((a, b) => a.diff(b))[0]
            ?.tz(timezone);
        if (!startTime) {
            return false;
        }
        const scheduledStartTime = timeSlots
            .map((slot) => moment.tz(slot.startTime, TIME_FORMAT, timezone))
            .sort((a, b) => a.diff(b))[0]
            ?.year(startTime.year())
            .month(startTime.month())
            .date(startTime.date());
        if (scheduledStartTime && startTime.diff(scheduledStartTime, 'minutes') > (latenessTolerance ?? LATENESS_IN_MINUTES)) {
            wasLate = true;
        }
        return wasLate;
    }

    private generateDateRange(startDate: string, endDate: string, timezone: string): Moment[] {
        const dates: Moment[] = [];
        const now = moment();
        const currentDate = moment.tz(startDate, ISO_DATE, timezone).hour(now.hour()).minute(now.minute()).second(now.second());
        const finishDate = moment.tz(endDate, ISO_DATE, timezone).hour(now.hour()).minute(now.minute()).second(now.second());
        while (currentDate.isSameOrBefore(finishDate)) {
            dates.push(currentDate.clone());
            currentDate.add(1, 'day');
        }
        return dates;
    }

    /* Used to pick between current day and previous day if night shifts */
    private chooseDaySummary = (daySummaries: TimeTrackingDaySummary[]): TimeTrackingDaySummary => {
        if (daySummaries.length === 0) {
            throw new IllegalStateError('No day summaries found', ErrorCodes.INTERNAL_SERVER_ERROR);
        }
        if (daySummaries.length === 1) {
            return daySummaries[0];
        }
        return daySummaries[0].state === State.OPEN && daySummaries[0].nightShift ? daySummaries[0] : daySummaries[1];
    };

    private handleTimeOffApproval = async (params: TimeOffRequestParams) => {
        const fromDate = params.timeOffRequest.from.date;
        const toDate = params.timeOffRequest.to.date;
        const userId = params.timeOffRequest.issuerId;

        // Fetch the TO request to get the amount of hours
        const fullTimeOffRequest = await this.timeOffListRequestsService.getRequestById(params.timeOffRequest.id);
        if (!fullTimeOffRequest) {
            throw new NotFoundError('Time off request not found', ErrorCodes.NOT_FOUND);
        }

        const daySummariesAffected = await this.timeTrackingRepository.getDaySummariesByUser(userId, fromDate, toDate, true);
        for (const daySummary of daySummariesAffected) {
            const stringDate = daySummary.dateString;
            const requestTimeOffHours = this.getTimeOffHours(stringDate, fullTimeOffRequest, daySummary.scheduledHours);
            daySummary.timeOffRequests = daySummary.timeOffRequests ?? [];
            daySummary.timeOffRequests?.push({
                activityType: fullTimeOffRequest.policyType?.activityType ?? null,
                consumptionType: this.getConsumptionTypeForDate(fullTimeOffRequest, stringDate),
                daySummaryId: daySummary.id,
                fromDate: fullTimeOffRequest.from.date,
                fromTime: fullTimeOffRequest.from.time ?? null,
                hours: requestTimeOffHours,
                id: -1,
                timeOffPolicyTypeIcon: params.timeOffRequest.policyType!.icon,
                timeOffPolicyTypeName: params.timeOffRequest.policyType!.name,
                timeOffRequestId: params.timeOffRequest.id,
                toDate: fullTimeOffRequest.to.date,
                toTime: fullTimeOffRequest.to.time ?? null,
            });

            const timeOffHours = await this.getTimeOffHoursFromTimeOffRequests(daySummary);
            const estimatedHours = daySummary.isWorkday
                ? fullTimeOffRequest.policyType?.activityType === TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES.NON_WORK
                    ? Math.max(daySummary.scheduledHours - timeOffHours, 0)
                    : daySummary.scheduledHours
                : 0;

            if (estimatedHours === 0) {
                daySummary.incidences = daySummary.incidences.filter((incidence) => !INCIDENCES_SCHEDULE_DEPENDENT.includes(incidence));
            } else if (daySummary.workedHours > estimatedHours - UNDERWORKED_THRESHOLD) {
                daySummary.incidences = daySummary.incidences.filter((incidence) => incidence !== Incidence.UNDERWORKED);
            }
            await this.timeTrackingRepository.getTransaction(async () => {
                await this.timeTrackingRepository.updateDaySummary(daySummary.id, {
                    estimatedHours,
                    incidences: daySummary.incidences,
                    timeOffHours,
                });
                await this.timeTrackingRepository.addTimeOffRequestToDaySummary(
                    fullTimeOffRequest.policyType?.activityType ?? null,
                    daySummary.id,
                    params.timeOffRequest.id,
                    params.timeOffRequest.policyType!.name,
                    params.timeOffRequest.policyType!.icon,
                    requestTimeOffHours,
                    this.getConsumptionTypeForDate(fullTimeOffRequest, stringDate),
                    fullTimeOffRequest.from.time,
                    fullTimeOffRequest.to.time,
                    fullTimeOffRequest.from.date,
                    fullTimeOffRequest.to.date,
                );
            });
        }

        if (daySummariesAffected.length > 0) {
            try {
                await this.scheduleCategorizedHoursPort.schedule({
                    payload: { endDate: toDate, instanceId: params.instanceId, startDate: fromDate, userIds: [userId] },
                    type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                });
            } catch (error) {
                this.logger.error(
                    { endDate: toDate, error, instanceId: params.instanceId, startDate: fromDate, userIds: [userId] },
                    'Failed to dispatch SCHEDULE_CHANGE on time off approval',
                );
            }
        }

        this.sendScheduleUpdatedPushNotificationToUsers([userId], params.instanceId, fromDate).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for time off approval');
        });
    };

    private sendScheduleUpdatedPushNotificationToUsers = async (
        userIds: number[],
        instanceId: number,
        date: string,
        isDisablingNotifications = false,
        isAssociating = false,
    ) => {
        if (!userIds || userIds.length === 0) return;
        const potentialUsersToBeNotified = userIds;
        let userIdsToBeNotified: number[] = [];
        const instance = await this.instanceService.findByPk(instanceId);
        const now = moment.tz(instance.getTimezone()).startOf('day').format(ISO_DATE);
        const maxDaysFromNow = moment.tz(instance.getTimezone()).add(MAX_DAYS_FROM_NOW_SCHEDULE, 'days').endOf('day').format(ISO_DATE);
        if (date >= now && date <= maxDaysFromNow) {
            if (isDisablingNotifications || isAssociating) {
                // if it's disabling notifications or its associating, we should send the push notification to all users
                userIdsToBeNotified = potentialUsersToBeNotified;
            } else {
                // Check if the user has notifications enabled in the policy for the date to send the push notification
                const policyAssociations = await this.timeTrackingPoliciesService.listPolicyAssociations(
                    {
                        fromDate: now,
                        instanceId: instanceId,
                        toDate: now,
                        userIds: potentialUsersToBeNotified,
                    },
                    {
                        limit: potentialUsersToBeNotified.length,
                        offset: 0,
                    },
                );
                policyAssociations.rows.forEach((association) => {
                    if (association.policy?.notificationsEnabled) {
                        userIdsToBeNotified.push(association.userId);
                    }
                });
            }
            if (userIdsToBeNotified.length === 0) return;
            const users = await this.usersService.findByPks(userIdsToBeNotified);
            this.timeTrackingNotificationsService.sendSilentPush(users, instance, isDisablingNotifications).catch((error) => {
                this.logger.error(error, 'Error sending schedule updated push notification to users');
            });
        }
    };

    private handleHolidayCreation = async (params: RegionHolidayParams) => {
        let daySummariesAffected = await this.timeTrackingRepository.getDaySummariesByInstanceAndDate(params.instanceId, params.holiday.date);
        const usersInRegion = new Set(await this.regionService.getUserIdsInRegion(params.holiday.regionId));
        daySummariesAffected = daySummariesAffected.filter((daySummary) => usersInRegion.has(daySummary.userId));
        const usersIdsAffected = daySummariesAffected.map((daySummary) => daySummary.userId);
        const instance = await this.instanceService.findByPk(params.instanceId);
        for (const daySummary of daySummariesAffected) {
            await this.addHolidayToDaySummary(daySummary, instance, { id: params.holiday.id, name: params.holiday.name });
        }
        if (daySummariesAffected.length > 0) {
            try {
                await this.scheduleCategorizedHoursPort.schedule({
                    payload: {
                        endDate: params.holiday.date,
                        instanceId: params.instanceId,
                        startDate: params.holiday.date,
                        userIds: usersIdsAffected,
                    },
                    type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                });
            } catch (error) {
                this.logger.error(
                    { endDate: params.holiday.date, error, instanceId: params.instanceId, startDate: params.holiday.date, userIds: usersIdsAffected },
                    'Failed to dispatch SCHEDULE_CHANGE on holiday creation',
                );
            }
        }
        this.sendScheduleUpdatedPushNotificationToUsers(usersIdsAffected, params.instanceId, params.holiday.date).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for holiday creation');
        });
    };

    private handleHolidayDeletion = async (params: RegionHolidayParams) => {
        let daySummariesAffected = await this.timeTrackingRepository.getDaySummariesByInstanceAndDate(params.instanceId, params.holiday.date);
        const usersInRegion = new Set(await this.regionService.getUserIdsInRegion(params.holiday.regionId));
        daySummariesAffected = daySummariesAffected.filter((daySummary) => usersInRegion.has(daySummary.userId));
        const usersIdsAffected = daySummariesAffected.map((daySummary) => daySummary.userId);
        const siteIds = new Set<number>();
        daySummariesAffected.forEach((daySummary) => {
            daySummary.timeTrackingEntries.forEach((entry) => {
                if (entry.siteId) siteIds.add(entry.siteId);
            });
        });
        const sites = await this.siteService.getByIds(Array.from(siteIds), params.instanceId, true);
        const instance = await this.instanceService.findByPk(params.instanceId);
        for (const daySummary of daySummariesAffected) {
            await this.removeHolidayFromDaySummary(daySummary, instance, params.holiday.id, sites);
        }
        if (daySummariesAffected.length > 0) {
            try {
                await this.scheduleCategorizedHoursPort.schedule({
                    payload: {
                        endDate: params.holiday.date,
                        instanceId: params.instanceId,
                        startDate: params.holiday.date,
                        userIds: usersIdsAffected,
                    },
                    type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                });
            } catch (error) {
                this.logger.error(
                    { endDate: params.holiday.date, error, instanceId: params.instanceId, startDate: params.holiday.date, userIds: usersIdsAffected },
                    'Failed to dispatch SCHEDULE_CHANGE on holiday deletion',
                );
            }
        }
        this.sendScheduleUpdatedPushNotificationToUsers(usersIdsAffected, params.instanceId, params.holiday.date).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for holiday deletion');
        });
    };

    private handleHolidayUpdate = async (params: RegionHolidayUpdateParams) => {
        const oldHoliday = params.oldHoliday;
        const newHoliday = params.newHoliday;
        await this.handleHolidayDeletion(oldHoliday);
        await this.handleHolidayCreation(newHoliday);
    };

    private addHolidayToDaySummary = async (
        daySummary: TimeTrackingDaySummary,
        instance: Instance,
        holiday: { id: number; name: string },
    ): Promise<void> => {
        if (daySummary.scheduleSource === DaySummaryScheduleSource.SHIFT) {
            const { estimatedHours, isWorkday } = await this.getEstimatedHoursAndTimeOffHoursAndIsWorkdayFromDaySummary(
                daySummary,
                instance.getTimezone(),
            );
            daySummary.estimatedHours = estimatedHours;
            daySummary.isWorkday = isWorkday;
        } else {
            daySummary.estimatedHours = 0;
            daySummary.incidences = daySummary.incidences.filter((incidence) => !INCIDENCES_SCHEDULE_DEPENDENT.includes(incidence));
            daySummary.isWorkday = false;
        }
        await this.timeTrackingRepository.getTransaction(async () => {
            await this.timeTrackingRepository.updateDaySummary(daySummary.id, {
                estimatedHours: daySummary.estimatedHours,
                incidences: daySummary.incidences,
                isWorkday: daySummary.isWorkday,
            });
            await this.timeTrackingRepository.addHolidayToDaySummary(daySummary.id, holiday.id, holiday.name);
        });
    };

    private removeHolidayFromDaySummary = async (
        daySummary: TimeTrackingDaySummary,
        instance: Instance,
        holidayId: number,
        sites: Site[],
    ): Promise<void> => {
        daySummary.holidays = daySummary.holidays?.filter((h) => h.holidayId !== holidayId);
        const { estimatedHours, isWorkday, timeOffHours } = await this.getEstimatedHoursAndTimeOffHoursAndIsWorkdayFromDaySummary(
            daySummary,
            instance.getTimezone(),
        );
        daySummary.estimatedHours = estimatedHours;
        daySummary.isWorkday = isWorkday;
        daySummary.incidences = this.shouldNotComputeIncidences(daySummary.timeOffRequests)
            ? []
            : this.computeMultipleEntriesIncidences(
                  daySummary.estimatedHours,
                  daySummary.workedHours,
                  daySummary.timeTrackingEntries,
                  instance.getTimezone(),
                  daySummary.timeSlots ?? [],
                  sites,
                  daySummary.latenessTolerance,
              );
        await this.timeTrackingRepository.getTransaction(async () => {
            await this.timeTrackingRepository.updateDaySummary(daySummary.id, {
                estimatedHours: daySummary.estimatedHours,
                incidences: daySummary.incidences,
                isWorkday: daySummary.isWorkday,
                timeOffHours,
            });
            await this.timeTrackingRepository.removeHolidayFromDaySummary(daySummary.id, holidayId);
        });
    };

    private async getEstimatedHoursAndTimeOffHoursAndIsWorkdayFromDaySummary(
        daySummary: TimeTrackingDaySummary,
        timezone: string,
    ): Promise<{ estimatedHours: number; isWorkday: boolean; timeOffHours: number }> {
        const timeOffHours = await this.getTimeOffHoursFromTimeOffRequests(daySummary);
        if (daySummary.holidays?.length && daySummary.scheduleSource !== DaySummaryScheduleSource.SHIFT) {
            return { estimatedHours: 0, isWorkday: false, timeOffHours: 0 };
        }
        if (daySummary.scheduleSource === DaySummaryScheduleSource.NO_SCHEDULE) return { estimatedHours: 0, isWorkday: false, timeOffHours: 0 };

        const isWorkday =
            daySummary.scheduleSource === DaySummaryScheduleSource.SHIFT
                ? await this.isWorkdayFromShiftAssignedDay(daySummary.shiftAssignedDayId!)
                : await this.isWorkdayFromWorkSchedule(daySummary.dateString, timezone, daySummary.instanceId, daySummary.workScheduleId!);

        if (!isWorkday) return { estimatedHours: 0, isWorkday, timeOffHours };
        const estimatedHours = daySummary.scheduledHours - timeOffHours;
        return { estimatedHours, isWorkday, timeOffHours };
    }

    private async getTimeOffHoursFromTimeOffRequests(daySummary: TimeTrackingDaySummary) {
        if (!daySummary.timeOffRequests?.length) return 0;

        if (daySummary.timeOffRequests?.every((request) => request.hours !== null)) {
            return daySummary.timeOffRequests
                .filter((request) => request.activityType !== TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES.WORK)
                .reduce((acc, request) => acc + (request.hours ?? 0), 0);
        } else {
            const timeOffRequests = await allPromises(
                daySummary.timeOffRequests.map((request) => async () => this.timeOffListRequestsService.getRequestById(request.timeOffRequestId)),
            );
            if (timeOffRequests.length) {
                if (timeOffRequests.length === 1) {
                    const request = timeOffRequests[0];
                    if (request && request.policyType?.activityType !== TIME_OFF_POLICY_TYPE_ACTIVITY_TYPES.WORK) {
                        if (this.isTimeOffRequestHours(request)) {
                            return request.amount;
                        }
                        if (this.isTimeOffRequestHalfDay(request)) {
                            return daySummary.scheduledHours / 2;
                        }
                        return 0;
                    }
                } else {
                    return 0;
                }
            }
        }
        return 0;
    }

    private async isWorkdayFromWorkSchedule(dateString: string, timezone: string, instanceId: number, workScheduleId: number) {
        const schedule = await this.workScheduleService.getWorkSchedule(workScheduleId, instanceId, timezone);
        const dayOfWeek: WORKDAYS = moment.tz(dateString, timezone).format('dddd').toUpperCase() as WORKDAYS;
        const scheduleDay = schedule?.scheduledDays.find((day) => day.dayOfWeek === dayOfWeek);
        return !!scheduleDay;
    }

    private async isWorkdayFromShiftAssignedDay(shiftAssignedDayId: number) {
        const shiftAssignedDay = await this.shiftAssignedDayService.getShiftAssignedDayById(shiftAssignedDayId);
        return shiftAssignedDay?.isWorkday ?? false;
    }

    private isTimeOffRequestHalfDay(request: TimeOffRequest) {
        return (
            request.from.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HALF_DAY ||
            request.to.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HALF_DAY
        );
    }

    private isTimeOffRequestHours(request: TimeOffRequest) {
        return (
            request.from.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HOURS ||
            request.to.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HOURS
        );
    }

    private getConsumptionTypeForDate(request: TimeOffRequest, dateString: string): TIME_OFF_REQUEST_CONSUMPTION_TYPES {
        const requestFromDate = request.from.date;
        const requestToDate = request.to.date;

        // If it's a single day request or the first day, uses from consumption type
        if (dateString === requestFromDate) {
            return request.from.consumptionType as TIME_OFF_REQUEST_CONSUMPTION_TYPES;
        }

        // If it's the last day, uses to consumption type
        if (dateString === requestToDate) {
            return request.to.consumptionType as TIME_OFF_REQUEST_CONSUMPTION_TYPES;
        }

        // For middle days is FULL_DAY
        return TIME_OFF_REQUEST_CONSUMPTION_TYPES.FULL_DAY;
    }

    private handleTimeOffCancellation = async (params: TimeOffRequestParams) => {
        const fromDate = params.timeOffRequest.from.date;
        const toDate = params.timeOffRequest.to.date;
        const userId = params.timeOffRequest.issuerId;
        const daySummariesAffected = await this.timeTrackingRepository.getDaySummariesByUser(userId, fromDate, toDate, true);
        const siteIds = new Set<number>();
        daySummariesAffected.forEach((daySummary) => daySummary.timeTrackingEntries.forEach((entry) => entry.siteId && siteIds.add(entry.siteId)));
        const sites = await this.siteService.getByIds(Array.from(siteIds), params.instanceId, true);
        for (const daySummary of daySummariesAffected) {
            const instance: Instance = await this.instanceService.findByPk(params.instanceId);
            if (!daySummary.holidays?.length) {
                daySummary.timeOffRequests = daySummary.timeOffRequests?.filter((request) => request.timeOffRequestId !== params.timeOffRequest.id);
                const { estimatedHours, timeOffHours } = await this.getEstimatedHoursAndTimeOffHoursAndIsWorkdayFromDaySummary(
                    daySummary,
                    instance.getTimezone(),
                );
                daySummary.estimatedHours = estimatedHours;
                daySummary.timeOffHours = timeOffHours;
                daySummary.incidences = this.shouldNotComputeIncidences(daySummary.timeOffRequests)
                    ? []
                    : this.computeMultipleEntriesIncidences(
                          estimatedHours,
                          daySummary.workedHours,
                          daySummary.timeTrackingEntries,
                          instance.getTimezone(),
                          daySummary.timeSlots ?? [],
                          sites,
                          daySummary.latenessTolerance,
                      );
            }
            await this.timeTrackingRepository.getTransaction(async () => {
                await this.timeTrackingRepository.updateDaySummary(daySummary.id, {
                    estimatedHours: daySummary.estimatedHours,
                    incidences: daySummary.incidences,
                    timeOffHours: daySummary.timeOffHours,
                });
                await this.timeTrackingRepository.removeTimeOffRequestFromDaySummary(daySummary.id, params.timeOffRequest.id);
            });
        }
        if (daySummariesAffected.length > 0) {
            try {
                await this.scheduleCategorizedHoursPort.schedule({
                    payload: { endDate: toDate, instanceId: params.instanceId, startDate: fromDate, userIds: [userId] },
                    type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                });
            } catch (error) {
                this.logger.error(
                    { endDate: toDate, error, instanceId: params.instanceId, startDate: fromDate, userIds: [userId] },
                    'Failed to dispatch SCHEDULE_CHANGE on time off cancellation',
                );
            }
        }
        this.sendScheduleUpdatedPushNotificationToUsers([userId], params.instanceId, fromDate).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for time off cancellation');
        });
    };

    private handleScheduleChange = async (params: { endDate?: string; userIds: number[]; startDate: string; instanceId: number }) => {
        await this.deleteDaySummariesForUsers(params.userIds, params.startDate, params.endDate);
        const namespace = getGlobalNamespace();
        const transaction = namespace.get('transaction') as Transaction;
        if (transaction) {
            transaction.afterCommit(() => {
                this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, params.startDate).catch((error) => {
                    this.logger.error(error, 'Error sending schedule updated push notification to users for schedule change');
                });
            });
        } else {
            this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, params.startDate).catch((error) => {
                this.logger.error(error, 'Error sending schedule updated push notification to users for schedule change');
            });
        }
    };

    private handleTimeTrackingPolicyUpdate = async (params: TimeTrackingPolicyUpdateParams) => {
        const instance = await this.instanceService.findByPk(params.instanceId);
        const startDate = moment.tz(instance.getTimezone()).format(ISO_DATE);
        await this.deleteDaySummariesForUsers(params.userIds, startDate, undefined);
        const isDisablingNotifications = params.newPolicy.notificationsEnabled === false && params.oldPolicy.notificationsEnabled === true;
        const isAssociating = false;
        this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, startDate, isDisablingNotifications, isAssociating).catch(
            (error) => {
                this.logger.error(error, 'Error sending schedule updated push notification to users for time tracking policy update');
            },
        );
    };

    private handleTimeTrackingPolicyUnassigned = async (params: TimeTrackingPolicyUnassignedParams) => {
        await this.deleteDaySummariesForUsers(params.userIds, params.startDate, undefined);
        const isDisablingNotifications = true;
        const isAssociating = false;
        this.sendScheduleUpdatedPushNotificationToUsers(
            params.userIds,
            params.instanceId,
            params.startDate,
            isDisablingNotifications,
            isAssociating,
        ).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for day summaries invalidated');
        });
    };

    private handleTimeTrackingPolicyAssigned = async (params: TimeTrackingPolicyAssignedParams) => {
        await this.deleteDaySummariesForUsers(params.userIds, params.startDate, undefined);
        const isDisablingNotifications = false;
        const isAssociating = true;
        this.sendScheduleUpdatedPushNotificationToUsers(
            params.userIds,
            params.instanceId,
            params.startDate,
            isDisablingNotifications,
            isAssociating,
        ).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for time tracking policy assigned');
        });
    };

    private handleShiftAssignedDayCreation = async (params: {
        endDate: string;
        startDate: string;
        userIds: number[];
        instanceId: number;
        deleteDaySummaries: boolean;
    }) => {
        if (params.deleteDaySummaries) {
            await this.deleteDaySummariesForUsers(params.userIds, params.startDate, params.endDate);
        }
        this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, params.startDate).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for shift assigned day creation');
        });
    };

    private getPastHolidaysByDateForRegion = async (regionId: number, today: string): Promise<Map<string, Holiday[]>> => {
        const { holidays } = await this.regionService.getHolidaysForRegion(regionId, { toDate: today });
        const byDate = new Map<string, Holiday[]>();
        for (const holiday of holidays) {
            const date = holiday.getDate();
            if (!date || date >= today) continue;
            const list = byDate.get(date);
            if (list) list.push(holiday);
            else byDate.set(date, [holiday]);
        }
        return byDate;
    };

    private handleRegionUsersAssigned = async (params: RegionUsersParams) => {
        try {
            const instance = await this.instanceService.findByPk(params.instanceId);
            const today = moment.tz(instance.getTimezone()).format(ISO_DATE);

            await this.deleteDaySummariesForUsers(params.userIds, today);

            const pastHolidaysByDate = await this.getPastHolidaysByDateForRegion(params.regionId, today);
            const touchedDaySummaries: TimeTrackingDaySummary[] =
                pastHolidaysByDate.size > 0
                    ? await this.timeTrackingRepository.getDaySummariesForUsersOnDates(params.userIds, Array.from(pastHolidaysByDate.keys()))
                    : [];

            if (touchedDaySummaries.length > 0) {
                await allPromises(
                    touchedDaySummaries.map((daySummary) => async () => {
                        const matchingHolidays = pastHolidaysByDate.get(daySummary.dateString) ?? [];
                        for (const holiday of matchingHolidays) {
                            const alreadyLinked = daySummary.holidays?.some((h) => h.holidayId === holiday.getId());
                            if (alreadyLinked) continue;
                            await this.addHolidayToDaySummary(daySummary, instance, { id: holiday.getId()!, name: holiday.getName() });
                        }
                    }),
                );

                const dates = touchedDaySummaries.map((d) => d.dateString);
                const endDate = dates.reduce((max, d) => (d > max ? d : max), dates[0]);
                const startDate = dates.reduce((min, d) => (d < min ? d : min), dates[0]);
                const userIds = Array.from(new Set(touchedDaySummaries.map((d) => d.userId)));
                try {
                    await this.scheduleCategorizedHoursPort.schedule({
                        payload: { endDate, instanceId: params.instanceId, startDate, userIds },
                        type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                    });
                } catch (error) {
                    this.logger.error(
                        { endDate, error, instanceId: params.instanceId, startDate, userIds },
                        'Failed to dispatch SCHEDULE_CHANGE on region users assigned',
                    );
                }
            }

            this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, today).catch((error) => {
                this.logger.error(error, 'Error sending schedule updated push notification to users for region users assigned');
            });
        } catch (error) {
            this.logger.error(
                error,
                `Error handling REGION_USERS_ASSIGNED (instanceId=${params.instanceId}, regionId=${params.regionId}, userIds=${params.userIds.join(',')})`,
            );
        }
    };

    private handleRegionUsersUnassigned = async (params: RegionUsersParams) => {
        try {
            const instance = await this.instanceService.findByPk(params.instanceId);
            const today = moment.tz(instance.getTimezone()).format(ISO_DATE);

            await this.deleteDaySummariesForUsers(params.userIds, today);

            const pastHolidaysByDate = await this.getPastHolidaysByDateForRegion(params.regionId, today);
            const touchedDaySummaries: TimeTrackingDaySummary[] =
                pastHolidaysByDate.size > 0
                    ? await this.timeTrackingRepository.getDaySummariesForUsersOnDates(params.userIds, Array.from(pastHolidaysByDate.keys()))
                    : [];

            if (touchedDaySummaries.length > 0) {
                const siteIds = new Set<number>();
                touchedDaySummaries.forEach((ds) =>
                    ds.timeTrackingEntries?.forEach((entry) => {
                        if (entry.siteId) siteIds.add(entry.siteId);
                    }),
                );
                const sites = siteIds.size ? await this.siteService.getByIds(Array.from(siteIds), params.instanceId, true) : [];
                await allPromises(
                    touchedDaySummaries.map((daySummary) => async () => {
                        const matchingHolidays = pastHolidaysByDate.get(daySummary.dateString) ?? [];
                        for (const holiday of matchingHolidays) {
                            const hasLink = daySummary.holidays?.some((h) => h.holidayId === holiday.getId());
                            if (!hasLink) continue;
                            await this.removeHolidayFromDaySummary(daySummary, instance, holiday.getId()!, sites);
                        }
                    }),
                );

                const dates = touchedDaySummaries.map((d) => d.dateString);
                const endDate = dates.reduce((max, d) => (d > max ? d : max), dates[0]);
                const startDate = dates.reduce((min, d) => (d < min ? d : min), dates[0]);
                const userIds = Array.from(new Set(touchedDaySummaries.map((d) => d.userId)));
                try {
                    await this.scheduleCategorizedHoursPort.schedule({
                        payload: { endDate, instanceId: params.instanceId, startDate, userIds },
                        type: CategorizedHoursActionType.SCHEDULE_CHANGE,
                    });
                } catch (error) {
                    this.logger.error(
                        { endDate, error, instanceId: params.instanceId, startDate, userIds },
                        'Failed to dispatch SCHEDULE_CHANGE on region users unassigned',
                    );
                }
            }

            this.sendScheduleUpdatedPushNotificationToUsers(params.userIds, params.instanceId, today).catch((error) => {
                this.logger.error(error, 'Error sending schedule updated push notification to users for region users unassigned');
            });
        } catch (error) {
            this.logger.error(
                error,
                `Error handling REGION_USERS_UNASSIGNED (instanceId=${params.instanceId}, regionId=${params.regionId}, userIds=${params.userIds.join(',')})`,
            );
        }
    };

    private handleTimezoneChange = async (params: { instanceId: number; timezone: string }) => {
        const today = moment.tz(params.timezone).format(ISO_DATE);
        await this.timeTrackingRepository.deleteDaySummariesForInstance(params.instanceId, today);

        const userIds = await this.usersService.getUserIdsInInstance(params.instanceId);
        this.sendScheduleUpdatedPushNotificationToUsers(userIds, params.instanceId, today).catch((error) => {
            this.logger.error(error, 'Error sending schedule updated push notification to users for timezone change');
        });
    };

    public getTimeTrackingPolicyHistoryLatenessTolerance = async (
        userId: number,
        fromDate: string,
        toDate: string,
    ): Promise<Array<{ dateString: string; latenessTolerance: number | null }>> => {
        return await this.timeTrackingRepository.getTimeTrackingPolicyHistoryLatenessTolerance(userId, fromDate, toDate);
    };

    public filterUsersBySites = async (
        instanceId: number,
        userIds: number[],
        fromDate: string,
        toDate: string,
        siteIds?: number[],
        excludeSiteIds?: number[],
    ): Promise<number[]> => {
        return await this.timeTrackingRepository.filterUsersBySites(instanceId, userIds, fromDate, toDate, siteIds, excludeSiteIds);
    };

    public listEntriesApi = async (instance: Instance, params: TimeTrackingEntriesApiFilter, pagination: OptionalLimitOffsetPagination) => {
        const timezone = instance.getTimezone();
        const dates = this.generateDateRange(params.startDate, params.endDate, timezone);

        const fromDate = dates[0].clone().startOf('day');
        const toDate = dates[dates.length - 1].clone().endOf('day');

        const user = await this.usersService.findByEmployeeInternalId(instance.getId(), params.employeeId, true);
        if (!user) throw new NotFoundError('Employee not found', ErrorCodes.USER_NOT_FOUND);

        const { rows: policiesAssociations } = await this.timeTrackingPoliciesService.listPolicyAssociations(
            { fromDate: fromDate.format(ISO_DATE), instanceId: instance.getId(), toDate: toDate.format(ISO_DATE), userIds: [user.id] },
            {},
        );

        const order: OrderItem[] = [['time', 'ASC']];
        const { count, rows: entries } = await this.timeTrackingRepository.listEntries(
            user.id,
            fromDate.toDate(),
            toDate.toDate(),
            pagination,
            order,
        );

        if (!entries.length) return { count, rows: [] };

        const groupedEntries = _.groupBy(entries, (entry) => moment.tz(entry.time, instance.getTimezone()).format(ISO_DATE));

        const firstEntry = entries[0];
        const firstEntryDateString = moment.tz(firstEntry.time, timezone).format(ISO_DATE);

        const firstEntryPolicy = policiesAssociations.find(
            (p) => p.fromDate <= firstEntryDateString && (!p.toDate || p.toDate <= firstEntryDateString),
        );

        if (firstEntry.type === TimeTrackingEntryType.END && firstEntryPolicy?.policy?.nightShift) {
            const previousDay = moment.tz(firstEntry.time, timezone).clone().subtract(1, 'day').startOf('day');
            const previousDayEnd = previousDay.clone().endOf('day');
            const { rows: previousEntries } = await this.timeTrackingRepository.listEntries(user.id, previousDay.toDate(), previousDayEnd.toDate(), {
                limit: 1,
                offset: 0,
            });
            if (previousEntries.length && previousEntries[0].type === TimeTrackingEntryType.START) {
                entries.shift();
                if (groupedEntries[firstEntryDateString]) {
                    groupedEntries[firstEntryDateString].shift();
                }
            }
        }
        // only had an END entry
        if (!entries.length) return { count: 0, rows: [] };

        const lastEntry = entries[entries.length - 1];
        const lastEntryDateString = moment.tz(lastEntry.time, timezone).format(ISO_DATE);

        const lastEntryPolicy = policiesAssociations.find((p) => p.fromDate <= lastEntryDateString && (!p.toDate || p.toDate <= lastEntryDateString));

        if (lastEntry.type === TimeTrackingEntryType.START && lastEntryPolicy?.policy?.nightShift) {
            const nextDay = moment.tz(lastEntry.time, timezone).clone().add(1, 'day').startOf('day');
            const nextDayEnd = nextDay.clone().endOf('day');
            const { rows: nextEntries } = await this.timeTrackingRepository.listEntries(
                user.id,
                nextDay.toDate(),
                nextDayEnd.toDate(),
                {
                    limit: 1,
                    offset: 0,
                },
                order,
            );
            if (nextEntries.length && nextEntries[0].type === TimeTrackingEntryType.END) {
                entries.push(nextEntries[0]);
                if (groupedEntries[lastEntryDateString]) {
                    groupedEntries[lastEntryDateString].push(nextEntries[0]);
                }
            }
        }

        const siteIds = new Set<number>();
        entries.forEach((entry) => entry.siteId && siteIds.add(entry.siteId));
        const sites = await this.siteService.getByIds(Array.from(siteIds), instance.getId(), true);
        const sitesMap = new Map<number, Site>();
        sites.forEach((site) => sitesMap.set(site.getId()!, site));

        const entriesApi = dates.flatMap((date) => {
            date.tz(timezone);
            const stringDate = date.format(ISO_DATE);
            const dayEntries = groupedEntries[stringDate] ?? [];

            return dayEntries.map((entry) =>
                mapModelEntryToApiEntry(entry, {
                    employeeId: params.employeeId,
                    referenceDate: stringDate,
                    site: sitesMap.get(entry.siteId!),
                }),
            );
        });

        return { count: entries.length, rows: entriesApi };
    };

    public createEntryTransactional = async (
        loggedUser: UserDAO,
        instance: Instance,
        params: CreateTimeTrackingEntryRequestsVC,
        loggedBotApp: UserBotApp | undefined,
        hasTimeTrackingDesertEagleEnabledFF = false,
        externalDesertEagleEntryId?: string,
    ): Promise<TimeTrackingEntry> => {
        const time = params.time ? moment(params.time, true).toDate() : new Date();
        const userId = params.userId ?? loggedUser.id;
        const isTransactional = !params.time || !params.eventTime;
        const eventTime = params.eventTime ? moment(params.eventTime, true).toDate() : undefined;
        const date = moment.tz(time, instance.getTimezone()).format(ISO_DATE);
        const lockKey = `create-entry:${date}:${userId}:${instance.getId()}`;
        let desertEagleEntryId: string | undefined;
        if (externalDesertEagleEntryId) {
            desertEagleEntryId = externalDesertEagleEntryId;
        }
        if (hasTimeTrackingDesertEagleEnabledFF && !externalDesertEagleEntryId) {
            const response = await this.timeTrackingEntriesRepository.createTimeTrackingEntry(
                this.createTimeTrackingEntryModel(loggedUser, params, time, userId, eventTime),
                loggedBotApp?.id,
            );
            if (!response.success) {
                if (response.errorCode === 'TIME_TRACKING_ENTRY_DUPLICATE') {
                    throw new RequestUnprocessableEntityError(DUPLICATE_TYPE_ERROR, ErrorCodes.TIME_TRACKING_ENTRY_DUPLICATE);
                }
                this.logger.error({ response }, 'Failed to create time tracking entry via gRPC');
                throw new Error(`Failed to create time tracking entry via gRPC: ${response.message}`);
            }
            desertEagleEntryId = response.entryId;
        }
        const createEntryPromise = async () => {
            if (!params.time) await this.preventDuplicateEntry(userId, time, instance, params.type, eventTime, desertEagleEntryId);
            return await this.createEntry(
                this.createTimeTrackingEntryModel(loggedUser, params, time, userId, eventTime, desertEagleEntryId),
                instance,
                loggedBotApp,
            );
        };

        try {
            if (isTransactional && !externalDesertEagleEntryId) {
                return await this.timeTrackingRepository.transactionWithAdvisoryLock(lockKey, createEntryPromise, {
                    isolationLevel: ISOLATION_LEVELS.READ_COMMITTED,
                    tries: 1,
                });
            }
            return await createEntryPromise();
        } catch (error) {
            if (error instanceof ResourceLockedError) {
                throw new RequestUnprocessableEntityError(DUPLICATE_TYPE_ERROR, ErrorCodes.TIME_TRACKING_ENTRY_DUPLICATE);
            }
            throw error;
        }
    };

    // Mirrors gRPC call logic from createEntryTransactional. Extracted here to allow
    // calling outside a DB transaction, avoiding stale reads from independent inner transactions.
    public createDesertEagleEntry = async (loggedUser: UserDAO, params: CreateTimeTrackingEntryRequestsVC): Promise<string> => {
        const time = params.time ? moment(params.time, true).toDate() : new Date();
        const userId = params.userId ?? loggedUser.id;
        const eventTime = params.eventTime ? moment(params.eventTime, true).toDate() : undefined;
        const response = await this.timeTrackingEntriesRepository.createTimeTrackingEntry(
            this.createTimeTrackingEntryModel(loggedUser, params, time, userId, eventTime),
        );
        if (!response.success) {
            if (response.errorCode === 'TIME_TRACKING_ENTRY_DUPLICATE') {
                throw new RequestUnprocessableEntityError(DUPLICATE_TYPE_ERROR, ErrorCodes.TIME_TRACKING_ENTRY_DUPLICATE);
            }
            this.logger.error({ response }, 'Failed to create time tracking entry via gRPC');
            throw new Error(`Failed to create time tracking entry via gRPC: ${response.message}`);
        }
        if (!response.entryId) {
            this.logger.error({ response }, 'gRPC returned success but missing entryId');
            throw new Error('gRPC response missing entryId despite success status');
        }
        return response.entryId;
    };

    private async preventDuplicateEntry(
        userId: number,
        time: Date,
        instanceOfLoggedUser: Instance,
        type: TimeTrackingEntryType,
        eventTime?: Date,
        desertEagleEntryId?: string,
    ) {
        const date = moment.tz(time, instanceOfLoggedUser.getTimezone()).format(ISO_DATE);
        const previousEntries = await this.listDayEntries(userId, date, instanceOfLoggedUser);
        if (!previousEntries?.length) return;
        // If the eventTime is the same as the last entry, remove the last entry
        if (eventTime) {
            const previousEntry = previousEntries[previousEntries.length - 1];
            if (previousEntry.eventTime?.getTime() == eventTime.getTime() && previousEntry.type == type) {
                previousEntries.pop();
                if (!previousEntries?.length) return;
            }
        }
        // Race condition: request arrives → entry is created in Desert Eagle → DE's Kinesis consumer
        // wins the race and integrates the same entry back via gRPC. Without filtering, this guard
        // sees an entry of the same type and throws 422, unaware it is the one we just created.
        // Filter by UUID (unique per gRPC call) regardless of chronological position or stored type.
        const entriesAfterUuidFilter = desertEagleEntryId
            ? previousEntries.filter((entry) => entry.desertEagleEntryId !== desertEagleEntryId)
            : previousEntries;
        if (!entriesAfterUuidFilter.length) return;

        const entries: { time: Date; type: TimeTrackingEntryType }[] = entriesAfterUuidFilter.map((entry) => ({
            time: entry.time,
            type: entry.type,
        }));
        entries.push({ time, type });
        entries.sort((a, b) => b.time.getTime() - a.time.getTime());
        if (entries.length > 1 && entries[0].type === entries[1].type) {
            throw new RequestUnprocessableEntityError(DUPLICATE_TYPE_ERROR, ErrorCodes.TIME_TRACKING_ENTRY_DUPLICATE);
        }
    }

    private createTimeTrackingEntryModel(
        loggedUser: UserDAO,
        params: CreateTimeTrackingEntryRequestsVC,
        time: Date,
        userId: number,
        eventTime?: Date,
        desertEagleEntryId?: string,
    ): TimeTrackingEntryPrototype {
        return {
            comment: params.comment,
            createdBy: loggedUser.id,
            desertEagleEntryId,
            eventTime,
            facialRecognitionSkipped: params.facialRecognitionSkipped ?? false,
            instanceId: loggedUser.instanceId,
            isRemoteWork: params.isRemoteWork ?? false,
            location: params.location,
            siteId: params.siteId,
            source: params.source ?? TimeTrackingEntrySource.APP,
            time: time,
            type: params.type,
            updatedBy: loggedUser.id,
            userId: userId,
        };
    }

    public getNotificationSchedule = async (
        userId: number,
        instance: Instance,
        params: TimeTrackingGetNotificationScheduleVC,
    ): Promise<TimeTrackingNotificationSchedule> => {
        const policyAssociations = await this.timeTrackingPoliciesService.listPolicyAssociations(
            {
                fromDate: params.startDate,
                instanceId: instance.getId(),
                toDate: params.endDate,
                userIds: [userId],
            },
            {
                limit: 1,
                offset: 0,
            },
        );
        if (policyAssociations.count === 0 || !policyAssociations.rows[0]?.policy?.notificationsEnabled)
            return new TimeTrackingNotificationSchedule({
                schedules: [],
            });

        const daySummaries = await this.getOrCreateDaySummariesByUser(userId, instance, params.startDate, params.endDate);
        const notificationSchedule = daySummaries.map((daySummary) => async () => {
            const showNotifications = this.shouldShowNotifications(daySummary);
            return new TimeTrackingNotificationScheduleItem(
                daySummary.dateString,
                daySummary.timeSlots?.map((timeSlot) =>
                    this.createTimeSlotIso(daySummary.dateString, timeSlot.startTime, timeSlot.endTime, instance.getTimezone()),
                ) ?? [],
                showNotifications,
            );
        });
        return new TimeTrackingNotificationSchedule({
            schedules: await allPromises(notificationSchedule),
        });
    };

    private shouldShowNotifications(daySummary: TimeTrackingDaySummary): boolean {
        // if the day summary has estimated hours and no time off requests or all time off requests are of type hours, should show notifications
        return (
            daySummary.estimatedHours > 0 &&
            (daySummary.timeOffRequests?.length === 0 ||
                (daySummary.timeOffRequests?.every((request) => request.consumptionType === TIME_OFF_REQUEST_CONSUMPTION_TYPES.HOURS) ?? true))
        );
    }

    private createTimeSlotIso(dateStr: string, startTime: string, endTime: string, timezone: string): TimeSlotIsoDatetime {
        const startDate = moment.tz(`${dateStr} ${startTime}`, 'YYYY-MM-DD HH:mm', timezone);
        let endDate = moment.tz(`${dateStr} ${endTime}`, 'YYYY-MM-DD HH:mm', timezone);

        // If the end time is before the start time, it means the time slot is next day (nightshift)
        if (endDate.isBefore(startDate)) {
            endDate = endDate.add(1, 'day');
        }

        return new TimeSlotIsoDatetime(startDate.toISOString(), endDate.toISOString());
    }

    public faceClock = async (
        instance: Instance,
        user: UserInterface,
        loggedBotApp: UserBotApp,
        hasTimeTrackingDesertEagleEnabledFeatureFlag = false,
    ): Promise<TimeTrackingEntry> => {
        const time = new Date();
        const currentDate = moment(time).tz(instance.getTimezone());
        const date = currentDate.format(ISO_DATE);
        const daySummaries = await this.getOrCreateDaySummariesByUser(user.getId(), instance, currentDate.subtract(1, 'day').format(ISO_DATE), date);
        const daySummary = this.chooseDaySummary(daySummaries);
        daySummary.timeTrackingEntries.sort(this.sortEntriesTimeAscending);
        const lastEntry = daySummary.timeTrackingEntries[daySummary.timeTrackingEntries.length - 1];
        const type = this.determineType(lastEntry);

        return this.createEntryTransactional(
            user as UserDAO,
            instance,
            { source: TimeTrackingEntrySource.KIOSK, type },
            loggedBotApp,
            hasTimeTrackingDesertEagleEnabledFeatureFlag,
        );
    };

    private determineType(lastEntry: TimeTrackingEntry): TimeTrackingEntryType {
        if (!lastEntry) return TimeTrackingEntryType.START;
        if (lastEntry.type === TimeTrackingEntryType.START) return TimeTrackingEntryType.END;
        return TimeTrackingEntryType.START;
    }

    public hasEntriesAfterDate = async (userId: number, dateString: string, instance: Instance): Promise<boolean> => {
        const nextDay = moment.tz(dateString, instance.getTimezone()).add(1, 'day').startOf('day').toDate();
        const now = new Date();
        const result = await this.timeTrackingRepository.listEntries(userId, nextDay, now, { limit: 1 });
        return result.count > 0;
    };

    private getTimeTrackingDesertEagleEnabledFeatureFlag = async (instanceId: number): Promise<boolean> => {
        const featureFlag = await this.featureFlagService.getBooleanFeatureFlag(FeatureFlagCode.TIME_TRACKING_DESERT_EAGLE_ENABLED, instanceId);
        return featureFlag.value;
    };

    public deleteEntryForPublicApi = async (entry: TimeTrackingEntry, deletedByUserId: number, instance: Instance): Promise<void> => {
        const hasTimeTrackingDesertEagleEnabledFF = await this.getTimeTrackingDesertEagleEnabledFeatureFlag(instance.getId());
        if (hasTimeTrackingDesertEagleEnabledFF) {
            const syncResult = await this.timeTrackingEntriesRepository.updateEntries(instance.getId(), [entry], [], []);
            if (!syncResult.success) {
                this.logger.error({ syncResult }, 'Failed to delete entry via HA');
                throw new Error(`Failed to delete entry: ${syncResult.message}`);
            }
        }
        await this.timeTrackingRepository.deleteEntry(this.mapDeletedEntryToUpdate(entry, deletedByUserId));
    };

    public deleteEntriesInRangeForPublicApi = async (
        instance: Instance,
        actor: UserInterface,
        targetUserId: number,
        startDate: string,
        endDate: string,
    ): Promise<void> => {
        // Query day summaries (not raw entries) so that night-shift entries whose time falls on the next
        // calendar day but belong to a summary within range are included.
        const daySummaries = await this.timeTrackingRepository.getDaySummariesByUser(targetUserId, startDate, endDate, true);
        const summariesWithEntries = daySummaries
            .filter((summary) => summary.timeTrackingEntries?.length)
            // Process most recent days first so that `hasEntriesAfterDate` inside `updateEntries` does not
            // see not-yet-deleted entries from later days in the same range and skip the Desert Eagle sync.
            .sort((a, b) => b.dateString.localeCompare(a.dateString));
        if (!summariesWithEntries.length) return;

        const hasTimeTrackingDesertEagleEnabledFF = await this.getTimeTrackingDesertEagleEnabledFeatureFlag(instance.getId());

        for (const summary of summariesWithEntries) {
            await this.updateEntries(instance, actor, summary.id, [], hasTimeTrackingDesertEagleEnabledFF);
        }
    };
}
